Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor c430ae70ad Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	Cargo.lock
2026-11-11 23:48:18 +00:00
FredApps 2ef7c3bed1 CI: nightly release dry run (#3088)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:03:49 +01:00
Dylan M. Taylor 5a6aa9b65f fix: bound or confine seven name, path and file inputs (#2732)
* fix: bound or confine seven name, path and file inputs (QSELECT wildcard, layout template name, LISP OPEN/CLOSE, font manifest, xref palette, test walkers)

* address review: reserved device names, fresh fifo test dir, stat before open in build.rs

* review: honour the name alias and Unicode case in exact xrefs, reserve Windows device basenames, charge line headers, skip non-directories in the module walk

* address review: distinct exact xref names, exact-capacity LISP line queue, fail the module walk on entry errors

* address review: xrefs differing only by case cannot be loaded together; panic-free default template name

* address review: refuse detach and path changes across case-variant xrefs; ASCII case folding for xref names

* address review: report the case-variant xref a load skips; assert both xref paths in the test
2026-11-11 16:11:19 -05:00
Dylan M. Taylor b460a3b8d9 fix(engine): an atomic replacement keeps the old file's group, or drops the group bits (#2743)
* fix(engine): an atomic replacement keeps the old file's group, or drops the group bits

* address review: backup group bits, skip no-op fchown

* fix(engine): keep atomic writes of one path serialized, backup bits capped by the drawing's group

* fix(engine): the first backup takes the drawing's group, else drops its group bits
2026-11-11 16:10:52 -05:00
Dylan M. Taylor cb96c182ef fix(fonts): one retained-point allowance per MTEXT layout and per stroke line (#3070)
* fix(fonts): one retained-point allowance per MTEXT layout and per stroke line

* fix(fonts): the layout allowance counts both retained copies
2026-11-11 16:10:25 -05:00
Dylan M. Taylor 0749bc78f9 address review: restore the dated measurement text in file-format-parity.md 2026-11-11 14:34:05 +00:00
Dylan M. Taylor 664837df89 Pin craft-dxf to the merge of storytold/craft-libs#796 2026-11-11 01:25:45 +00:00
Dylan M. Taylor 2c953e38a3 Use craft-dxf from craft-libs; remove crates/dxf
cadcraft-dxf moved to craft-libs unchanged as craft-dxf
(storytold/craft-libs#791) so VectorCraft can write DXF through the same tag
layer. CADCRAFT_MAX_DXF_TAGS still sets the tag limit.
2026-11-11 00:13:34 +00:00
75 changed files with 670 additions and 2491 deletions
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "8 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -76,6 +80,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
Generated
+13 -12
View File
@@ -695,15 +695,6 @@ dependencies = [
"log",
]
[[package]]
name = "cadcraft-dxf"
version = "0.20.1"
dependencies = [
"encoding_rs",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "cadcraft-engine"
version = "0.20.1"
@@ -711,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",
@@ -729,8 +720,8 @@ dependencies = [
name = "cadcraft-fonts"
version = "0.20.1"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
"craft-dxf",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
@@ -753,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",
@@ -1152,6 +1143,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
View File
@@ -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" }
-14
View File
@@ -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 }
-398
View File
@@ -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);
}
}
}
-925
View File
@@ -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}");
}
}
-117
View File
@@ -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);
}
}
-19
View File
@@ -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());
}
-75
View File
@@ -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());
}
-116
View File
@@ -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}");
}
}
}
-145
View File
@@ -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")]);
}
}
}
-220
View File
@@ -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");
}
}
-119
View File
@@ -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);
}
}
}
}
-148
View File
@@ -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(&current, version.utf8()) } else { write_ascii_as(&current, 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, ""),
],
);
}
+1 -1
View File
@@ -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 }
+85 -1
View File
@@ -16,10 +16,31 @@ use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// Locks held from destination metadata inspection through rename, so concurrent writes cannot
/// combine permissions from one version of a path with the group of another.
fn path_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
let key = match (absolute.parent(), absolute.file_name()) {
(Some(parent), Some(name)) => std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()).join(name),
_ => absolute,
};
let mut locks = LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())).lock().unwrap_or_else(std::sync::PoisonError::into_inner);
locks.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
lock
} else {
let lock = Arc::new(Mutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
lock
}
}
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
@@ -33,13 +54,15 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
// A symlinked destination is resolved so the link stays a link and its target is replaced.
let resolved = resolve_symlink(path)?;
let path = resolved.as_deref().unwrap_or(path);
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
write_atomic_with(path, permissions, |file| file.write_all(bytes))
write_atomic_with_locked(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
@@ -67,10 +90,30 @@ pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()>
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
/// at `path` is replaced by the new file, never written through.
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
write_atomic_with_locked(path, permissions, fill)
}
/// [`write_atomic_with`] with permissions derived under the same destination lock as the write.
pub fn write_atomic_with_permissions(
path: &Path,
permissions: impl FnOnce() -> io::Result<Option<Permissions>>,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = permissions()?;
write_atomic_with_locked(path, permissions, fill)
}
fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
#[cfg(unix)]
let permissions = keep_group(&file, path, permissions);
file.set_permissions(permissions)?;
}
fill(&mut file)?;
@@ -90,6 +133,20 @@ pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: im
Ok(())
}
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
/// if the file is still in the old file's group, so the group is restored first. When it can't be
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
#[cfg(unix)]
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
return permissions;
}
Permissions::from_mode(permissions.mode() & !0o070)
}
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
/// refuse to sync a directory, and the file itself is already on disk.
@@ -231,6 +288,33 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
/// says) together with its mode, so the group bits never name a different group.
#[cfg(unix)]
#[test]
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&dest).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&dest, None, Some(gid)).unwrap();
}
let want = std::fs::metadata(&dest).unwrap();
write_atomic(&dest, b"new").unwrap();
let got = std::fs::metadata(&dest).unwrap();
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
+56 -1
View File
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("-xref", "External References", run_dash_xref)
.alias(&["-xr"])
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
CommandSpec::new("xbind", "Bind...", run_xbind)
.menu(&["Modify", "Object", "External Reference", "Bind..."])
@@ -201,6 +201,46 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
/// The xrefs a JSON call names (`names`, default all); an error when none match.
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
if p.get("exact").and_then(Value::as_bool) == Some(true) {
let lits: Vec<&str> = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
Some(Value::String(t)) => vec![t.as_str()],
_ => str_param(p, "name").into_iter().collect(),
};
let d = s.doc()?;
let mut names = Vec::new();
let mut selected = std::collections::HashSet::new();
// Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together.
let mut folded = std::collections::HashMap::new();
for literal in &lits {
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
continue;
}
let normalized = literal.to_ascii_lowercase();
let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized);
if let Some(block) = matches.next() {
if matches.next().is_some() {
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
}
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
}
}
if names.is_empty() {
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
}
return Ok(names);
}
let pattern = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
Some(Value::String(t)) => t.clone(),
@@ -223,6 +263,19 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(r)
}
/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only
/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the
/// command must refuse before it changes anything.
fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> {
let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() };
for n in names {
if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) {
return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case")));
}
}
Ok(())
}
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
let host = host(s);
@@ -252,6 +305,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"detach" | "d" => {
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, true)?;
let mut erased = 0;
for n in &names {
erased += load::detach(s.doc_mut()?, n);
@@ -276,6 +330,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
"path" | "p" => {
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, false)?;
let d = s.doc_mut()?;
for n in &names {
if let Some(b) = d.blocks.get_mut(n) {
+17 -1
View File
@@ -313,7 +313,23 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool
/// [`load`] the xrefs `names` within one set of limits.
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
let mut cx = Ctx::new(host);
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
// Names are case-insensitive, so a name that differs only by case from an earlier one is the same xref.
let mut seen = std::collections::HashMap::new();
names
.iter()
.flat_map(|n| match seen.entry(n.to_ascii_lowercase()) {
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(n.as_str());
load_with(d, n, host, chosen, &mut cx)
}
// Reported, not silent: the skipped xref keeps its status and is not loaded here.
std::collections::hash_map::Entry::Occupied(o) => vec![Outcome {
name: String::new(),
status: XrefStatus::Unresolved,
detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()),
}],
})
.collect()
}
impl Ctx {
+101 -17
View File
@@ -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);
@@ -625,6 +625,40 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
/// differs from the drawing's group, its group bits are dropped and its other bits are capped by
/// the drawing's group bits: a member of the drawing's group must not gain access through `other`.
#[cfg(unix)]
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
let mut mode = src_mode & 0o777;
if let Some((old_mode, old_gid)) = old {
mode &= old_mode;
if old_gid != src_gid {
mode = without_group(src_mode, mode);
}
}
mode
}
/// `mode` for a backup that is not in the drawing's group: no group bits, and `other` capped by the
/// drawing's group bits (`src_mode`).
#[cfg(unix)]
fn without_group(src_mode: u32, mode: u32) -> u32 {
(mode & !0o070) & (!0o007 | ((src_mode & 0o070) >> 3))
}
/// A first backup is created in the process's (or folder's) group, not the drawing's: move it to the
/// drawing's group before any data is written, else drop its group bits ([`without_group`]).
#[cfg(unix)]
fn adopt_source_group(out: &std::fs::File, src: &std::fs::Metadata) -> std::io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
if out.metadata()?.gid() == src.gid() || fchown(out, None, Some(src.gid())).is_ok() {
return Ok(());
}
out.set_permissions(std::fs::Permissions::from_mode(without_group(src.mode(), src.mode() & 0o777)))
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
@@ -648,19 +682,23 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
// written through.
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
let source_metadata = src.metadata()?;
#[cfg(unix)]
let first_backup = std::cell::Cell::new(false);
#[cfg(unix)]
let permissions = || {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
first_backup.set(old.is_none());
Ok(Some(std::fs::Permissions::from_mode(backup_mode(source_metadata.mode(), source_metadata.gid(), old.map(|m| (m.mode(), m.gid()))))))
};
#[cfg(not(unix))]
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
let permissions = || Ok(None);
crate::atomic_write::write_atomic_with_permissions(&bak, permissions, |out| {
#[cfg(unix)]
if first_backup.get() {
adopt_source_group(out, &source_metadata)?;
}
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
@@ -1031,6 +1069,52 @@ mod tests {
use crate::Session;
use crate::temp_dir::TempDir;
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
/// it would keep the old `.bak`'s group while the drawing is in another.
#[cfg(unix)]
#[test]
fn backup_mode_drops_group_bits_for_a_different_group() {
use super::backup_mode;
assert_eq!(backup_mode(0o640, 10, None), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
}
/// With no `.bak` yet, the first backup takes the drawing's group (when the user is in another
/// group, as `id -G` says), never the group the new file would be created in.
#[cfg(unix)]
#[test]
fn first_backup_takes_the_drawings_group() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = TempDir::new("first-bak-grp");
let src = dir.join("a.dxf");
std::fs::write(&src, "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n").unwrap();
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&src).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&src, None, Some(gid)).unwrap();
}
super::backup(&src).unwrap();
let (s, b) = (std::fs::metadata(&src).unwrap(), std::fs::metadata(super::backup_path(&src)).unwrap());
assert_eq!((b.gid(), b.mode() & 0o777), (s.gid(), 0o640));
}
/// The fail-safe when the group cannot be adopted: no group bits, `other` capped by the group's.
#[cfg(unix)]
#[test]
fn without_group_drops_group_bits_and_caps_other() {
assert_eq!(super::without_group(0o640, 0o640), 0o600);
assert_eq!(super::without_group(0o604, 0o604), 0o600);
assert_eq!(super::without_group(0o644, 0o644), 0o604);
}
fn active_title(s: &Session) -> String {
s.state().unwrap().title.clone()
}
+52 -4
View File
@@ -26,11 +26,30 @@ const MAX_PURGE_PASSES: usize = 64;
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
pub(super) fn default_path(s: &Session, layout: &str) -> String {
let file = format!("{layout}.dwt");
match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
None => file,
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
// beside the drawing, so those become `_`.
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file),
None => std::path::PathBuf::from(file),
};
if !candidate.exists() {
return candidate.to_string_lossy().into_owned();
}
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
// The suffix range never ends in practice; the fallback only keeps the code panic-free.
let alternative = (2u64..)
.map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}")))
.find(|alternative| !alternative.exists())
.unwrap_or(candidate.clone());
alternative.to_string_lossy().into_owned()
}
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
@@ -133,3 +152,32 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
}
}
#[cfg(test)]
mod default_path_tests {
use super::*;
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
#[test]
fn layout_name_is_one_file_name() {
let s = Session::empty();
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
let p = default_path(&s, name);
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
assert!(p.ends_with(".dwt"));
}
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
}
/// Windows device names are prefixed so the suggestion is never a device.
#[test]
fn reserved_device_names_are_prefixed() {
let s = Session::empty();
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
}
for name in ["COM0", "CONSOLE", "COM10"] {
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
}
}
}
+3 -2
View File
@@ -108,8 +108,9 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
pub fn wildcard(pat: &str, text: &str) -> bool {
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
// Iterative matcher with single back-track point (linear-ish, no recursion).
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
+47 -4
View File
@@ -27,8 +27,14 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
if !writing {
match read_text_file(&path) {
Some(t) => {
charge_bytes(t.len())?;
f.lines = t.lines().map(str::to_string).collect();
// Each line has a String header and a separately allocated buffer. Charge a
// conservative 64 bytes per line for both, including the allocator's minimum
// allocation size, before collecting so many short lines cannot evade the budget.
let count = t.lines().count();
charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?;
// Exact capacity: collecting would grow by doubling and hold unbilled slack.
f.lines = std::collections::VecDeque::with_capacity(count);
f.lines.extend(t.lines().map(str::to_string));
}
None => return Ok(Flow::Value(Value::Nil)),
}
@@ -100,8 +106,19 @@ fn can_write(_path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
fn write_text_file(path: &str, text: &str, append: bool) {
use std::io::Write;
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
if let Ok(mut f) = f {
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
// instead of stalling the interpreter; only a regular file is then truncated and written.
let mut o = std::fs::OpenOptions::new();
o.create(true).write(true).append(append);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.custom_flags(libc::O_NONBLOCK);
}
if let Ok(mut f) = o.open(path)
&& f.metadata().is_ok_and(|m| m.is_file())
&& (append || f.set_len(0).is_ok())
{
let _ = f.write_all(text.as_bytes());
}
}
@@ -118,3 +135,29 @@ fn file_exists(path: &str) -> bool {
fn file_exists(_path: &str) -> bool {
false
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
#[test]
fn write_to_a_fifo_without_a_reader_does_not_block() {
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
write_text_file(fifo.to_str().unwrap(), "x", false);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
let f = dir.join("f.txt");
std::fs::write(&f, "long old text").unwrap();
write_text_file(f.to_str().unwrap(), "new", false);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
write_text_file(f.to_str().unwrap(), "+", true);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
std::fs::remove_dir_all(&dir).ok();
}
}
+8 -8
View File
@@ -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;
@@ -253,20 +253,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)),
}
}
+4 -4
View File
@@ -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)
})
+22
View File
@@ -190,6 +190,28 @@ fn reload_unload_path_and_detach() {
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
// Two xrefs that differ only by case: an exact selection of both is refused before anything loads,
// and reload-all loads one of them once.
let mut twin = Block::new("SITE");
twin.xref_path = Some("site.dxf".into());
s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin));
assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err());
let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap();
assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1));
// Detaching one of them, or giving both a new path, is refused before anything changes.
assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err());
assert!(s.doc().unwrap().block("site").is_some());
assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err());
for k in ["site", "SITE"] {
assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}");
}
s.doc_mut().unwrap().blocks.remove("SITE");
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
assert!(s.doc().unwrap().block("site|Tree").is_none());
+1 -1
View File
@@ -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 }
+28 -3
View File
@@ -43,13 +43,16 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
rerun_if_changed(&manifest)?;
// Resolve the checkout root once so every manifest file can be confined inside it.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
// The line's text is not repeated: it comes from the checkout and goes to the build output.
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
return Err(format!("malformed manifest line {n}"));
};
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
@@ -73,6 +76,28 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
Ok(out)
}
/// The most a manifest may hold; the real one is a few KiB.
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
use std::io::Read as _;
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !file.metadata().is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let mut text = String::new();
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
if text.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
}
Ok(text)
}
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
///
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
+10 -1
View File
@@ -184,6 +184,9 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
let mut under_start: Option<f64> = None;
let mut over_start: Option<f64> = None;
let mut gap = stroke::GAP * sc * wf;
// Retained fallback-outline points over the line, as the TrueType path caps them: a short string
// repeating one detailed glyph would otherwise copy its outline per occurrence.
let mut points_left = ttf::MAX_SHAPED_POINTS;
// The simple path lays a line out left to right: one asked to run right to left goes through
// the bidi path even without right-to-left letters.
if (complex::needed(s) || base_rtl == Some(true))
@@ -202,7 +205,13 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
Some(g) => {
let (y, scale) = stroke_fallback_metrics(&g, c, h);
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
let contours = match points_left.checked_sub(g.points) {
Some(rest) => {
points_left = rest;
ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear)
}
None => Vec::new(),
};
if !contours.is_empty() {
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
}
+55 -1
View File
@@ -153,6 +153,40 @@ pub struct MTextLayout {
pub bounds: Bounds2,
/// Plain text of each laid-out line.
pub lines: Vec<String>,
/// Whether the geometry stops short: the layout reached its allowance of retained points
/// ([`Allowance`]) and the segments after that are not drawn.
pub truncated: bool,
}
/// The points all the segments of one layout may keep together, each counted for both copies the
/// layout holds: the allowance one shaped line has (`MAX_SHAPED_POINTS`), so a short repeated detailed glyph over many segments cannot multiply it.
pub(crate) struct Allowance(usize);
impl Allowance {
pub(crate) fn new() -> Self {
Allowance(crate::ttf::MAX_SHAPED_POINTS)
}
/// Whether any allowance is left, before a segment is shaped.
pub(crate) fn left(&self) -> bool {
self.0 > 0
}
/// Charge the points of `sh`; false (and nothing left) when it does not fit.
pub(crate) fn charge(&mut self, sh: &Shaped) -> bool {
// Kept twice: in the piece and again in the layout's flat `strokes`.
let n = 2 * (sh.strokes.iter().map(Vec::len).sum::<usize>() + sh.glyphs.iter().flatten().map(Vec::len).sum::<usize>());
match self.0.checked_sub(n) {
Some(rest) => {
self.0 = rest;
true
}
None => {
self.0 = 0;
false
}
}
}
}
/// Inline character format.
@@ -259,7 +293,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;
}
@@ -850,6 +884,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = Allowance::new();
for (l, base_y) in lines.iter().zip(baselines) {
let x0 = match col {
0 => 0.0,
@@ -859,7 +894,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
for it in &l.items {
let Some(sg) = &it.seg else { continue };
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(sg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(x0, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
@@ -896,6 +939,17 @@ mod tests {
assert_eq!(l.lines[0], "alpha beta gamma");
}
/// The points of all the segments of one layout are charged against one allowance.
#[test]
fn a_layout_shares_one_point_allowance() {
let big = Shaped { strokes: vec![vec![Vec2::ZERO; crate::ttf::MAX_SHAPED_POINTS / 4 + 1]], ..Default::default() };
let mut a = Allowance::new();
assert!(a.left() && a.charge(&big), "the first segment fits");
assert!(!a.charge(&big), "the second does not, though each alone would");
assert!(!a.left(), "and nothing is left to shape another");
assert!(!layout_mtext("plain text", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0).truncated);
}
#[test]
fn strips_formatting() {
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
+9
View File
@@ -677,6 +677,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = super::Allowance::new();
for (l, (c, base_y)) in lines.iter().zip(placed) {
let pp = paras.props(l.para);
let slot = if reversed { n_cols.saturating_sub(1).saturating_sub(c) } else { c };
@@ -696,7 +697,15 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
};
for it in &l.items {
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(&it.seg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(col_x + shift + per_gap * it.gap as f64, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
+1 -1
View File
@@ -932,7 +932,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
#[derive(Default)]
pub(crate) struct GlyphOutline {
pub(crate) contours: Arc<[Vec<Vec2>]>,
points: usize,
pub(crate) points: usize,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
+1 -1
View File
@@ -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 }
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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),
+2 -2
View File
@@ -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()));
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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};
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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() }
}
+5 -5
View File
@@ -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
+6 -6
View File
@@ -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()));
+8 -8
View File
@@ -1493,7 +1493,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();
@@ -1522,7 +1522,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)");
}
@@ -1618,7 +1618,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 {
@@ -1631,7 +1631,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);
@@ -1707,7 +1707,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()
@@ -1736,13 +1736,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!(),
@@ -2051,7 +2051,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"));
+4 -4
View File
@@ -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}");
+3 -3
View File
@@ -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 = &sections.iter().find(|s| s.name == "HEADER").unwrap().tags;
for (name, unit) in [("$SNAPUNIT", snap), ("$GRIDUNIT", grid)] {
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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()) };
+2 -2
View File
@@ -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();
+15 -15
View File
@@ -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)
}
+1 -1
View File
@@ -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())),
+2 -2
View File
@@ -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(&sections(&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);
+2 -2
View File
@@ -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(&sections.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(&sections.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(&sections.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(&sections.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
assert_eq!(records.len(), 1);
+1 -1
View File
@@ -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(&current, "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(&sections.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();
+3 -3
View File
@@ -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(&sections.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");
+3 -3
View File
@@ -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);
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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")
+15 -18
View File
@@ -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(&sections.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(&sections.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 {
+17 -5
View File
@@ -161,13 +161,25 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
/// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned.
fn module_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if !std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_dir()) {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
// `file_type` doesn't follow links, so a symlink (a directory alias or a file) is skipped,
// not walked again as a new subtree.
// An unreadable entry must fail the scan, not silently shrink the translation report.
let mut paths: Vec<_> = entries
.map(|e| {
let e = e.expect("read module directory entry");
let kind = e.file_type().expect("read module entry type");
(e.path(), kind)
})
.collect();
paths.sort_by(|a, b| a.0.cmp(&b.0));
for (path, kind) in paths {
if kind.is_dir() {
module_files(&path, files);
} else if path.extension().is_some_and(|x| x == "rs") {
} else if kind.is_file() && path.extension().is_some_and(|x| x == "rs") {
files.push(path);
}
}
+4 -4
View File
@@ -197,7 +197,7 @@ pub fn section(app: &mut CadApp, ui: &mut egui::Ui) {
fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>) {
let name = r.name.clone();
let loaded = r.status == "Loaded" || r.status == "Unreferenced";
let op = |option: &str| json!({ "option": option, "names": name });
let op = |option: &str| json!({ "option": option, "names": name, "exact": true });
ui.horizontal_wrapped(|ui| {
ui.add_space(8.0);
if ui.button(crate::tl!("Reload")).clicked() {
@@ -221,7 +221,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
.on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names"))
.clicked()
{
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true })));
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "exact": true, "insert": true })));
}
if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() {
*act = Some(Act::Run("xopen", json!({ "name": name })));
@@ -235,7 +235,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked();
let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked();
if (a || o) && overlay != r.overlay {
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay })));
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "exact": true, "overlay": overlay })));
}
});
ui.add_space(4.0);
@@ -248,7 +248,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path);
if apply {
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() })));
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "exact": true, "path": text.trim() })));
ui.data_mut(|d| d.remove::<String>(path_id));
} else if text != r.path {
ui.data_mut(|d| d.insert_temp(path_id, text));
-1
View File
@@ -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)),
+14 -4
View File
@@ -136,6 +136,18 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
mod tests {
use super::{Counts, count_tests};
/// A new private directory in the temp folder. It is created, never reused: an existing path
/// (planted by another user, or a symlink) makes the test fail instead of redirecting its writes.
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let p = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{nanos}", std::process::id()));
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b.create(&p).unwrap();
p
}
#[test]
fn counts_attributes_and_proptest_blocks() {
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
@@ -147,8 +159,7 @@ mod tests {
#[test]
fn walk_does_not_follow_directory_symlinks() {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let base = fresh_dir("xtask");
let real = base.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
@@ -162,8 +173,7 @@ mod tests {
/// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it.
#[test]
fn walk_reports_budget_exhaustion() {
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let base = fresh_dir("stats-budget");
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
assert!(error.contains("budget exhausted"));