Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 59468ec6e5 test: avoid unchecked option parsing in Flatpak regression 2026-11-11 14:08:46 -05:00
Dylan M. Taylor f9dd53a77e Release 0.20.1: fix Flatpak update permissions 2026-11-11 14:04:24 -05:00
FredApps 28241abb8f Merge main into release for CADCraft v0.20.0 2026-11-11 16:38:54 +01:00
FredAppsand.CooCoo 51f5bbcc21 Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954) (#3024)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.

(cherry picked from commit 6f0f1fefa8)

Co-authored-by: .CooCoo <RobertLeeLW@gmail.com>
2026-11-10 19:49:24 +01:00
Dylan M. Taylor 227e3c385b Release 0.19.0 2026-11-09 06:29:44 -05:00
137 changed files with 5810 additions and 1585 deletions
+36 -2
View File
@@ -23,5 +23,39 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
-4
View File
@@ -27,10 +27,6 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+39 -6
View File
@@ -1,10 +1,6 @@
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:
@@ -28,9 +24,47 @@ jobs:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: credits
needs: [git-sources, credits]
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
@@ -38,7 +72,6 @@ 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
+22 -26
View File
@@ -687,6 +687,23 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "cadcraft-dwg"
version = "0.20.1"
dependencies = [
"acadrust",
"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"
@@ -694,12 +711,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",
@@ -712,8 +729,8 @@ dependencies = [
name = "cadcraft-fonts"
version = "0.20.1"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
"craft-dxf",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
@@ -735,11 +752,11 @@ dependencies = [
"acadrust",
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
"cadcraft-dxf",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-dwg",
"craft-dxf",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
@@ -788,7 +805,6 @@ dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"chrono",
"craft-ui",
@@ -1136,25 +1152,6 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-dwg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf#7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf"
dependencies = [
"acadrust",
"log",
]
[[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"
@@ -1217,7 +1214,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1227,7 +1224,6 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
+3 -4
View File
@@ -13,9 +13,8 @@ repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
# internal
cadcraft-geom = { path = "crates/geom" }
# 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" }
craft-dwg = { git = "https://github.com/storytold/craft-libs", rev = "7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" }
cadcraft-dxf = { path = "crates/dxf" }
cadcraft-dwg = { path = "crates/dwg" }
cadcraft-color = { path = "crates/color" }
cadcraft-doc = { path = "crates/doc" }
cadcraft-constraints = { path = "crates/constraints" }
@@ -72,7 +71,7 @@ egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "cadcraft-dwg"
description = "CADCraft DWG bridge: converts DWG to and from DXF bytes using the acadrust crate (MPL-2.0, used unmodified)"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
log = { workspace = true }
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.6"
+12
View File
@@ -0,0 +1,12 @@
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
//! there return an error that says to use DXF.
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
println!("cargo::rustc-cfg=no_dwg_bridge");
}
}
File diff suppressed because it is too large Load Diff
+632
View File
@@ -0,0 +1,632 @@
//! Another application's entities and their classes through the DWG bridge: on reading a DWG,
//! registered entities acadrust has no model for become proxy entities of their class; on writing
//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class.
use std::collections::{HashMap, HashSet};
use acadrust::CadDocument;
use acadrust::classes::{DxfClass, ProxyFlags};
use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData};
use acadrust::objects::{ObjectType, ProxyPayload};
fn class_number(index: usize) -> Result<i16, String> {
index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into())
}
/// After a DWG read: each registered entity of another application's class that acadrust keeps
/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class
/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object
/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's
/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is
/// missing or not unique) is left out and named in the notes; one with decoded properties stays as
/// read, also named, as its opaque data has no DXF form beside them.
pub(super) fn prepare(doc: &mut CadDocument) -> Vec<String> {
let classes: Vec<_> = doc.classes.iter().cloned().collect();
let mut numbers = HashMap::new();
let mut identities = HashMap::new();
for (index, class) in classes.iter().enumerate() {
let number = class_number(index).ok();
numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from));
let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity);
identities.entry(identity).and_modify(|v| *v = None).or_insert(number);
}
let identity = |dxf: &str, cpp: &str, entity: bool| {
identities
.get(&(dxf, cpp, entity))
.copied()
.flatten()
.map(i32::from)
.ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})"))
};
let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() };
let maintenance = i32::from(doc.maintenance_version);
let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}"));
// Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity.
let handles: Vec<_> = doc
.entities()
.filter_map(|e| match e {
EntityType::Extended(e) => match &e.data {
ExtendedEntityData::Proxy(_) => Some(e.common.handle),
ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle),
_ => None,
},
_ => None,
})
.collect();
let mut notes = Vec::new();
let mut entity_count = 0usize;
let mut object_count = 0usize;
let mut semantic = 0usize;
for handle in handles {
// Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties.
let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Option<Option<ExtendedEntityData>>, String> {
let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) };
let data = match &entity.data {
ExtendedEntityData::Proxy(data) => {
let mut proxy = data.clone();
proxy.class_id = remap(proxy.class_id)?;
ExtendedEntityData::Proxy(proxy)
}
ExtendedEntityData::RegisteredClass(data) => {
if !data.properties.is_empty() {
return Ok(Some(None));
}
let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?;
let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default();
ExtendedEntityData::Proxy(ProxyEntityData {
proxy_id: 498,
class_id,
dxf_subclass: String::new(),
version: (maintenance << 16) | dwg_version,
dwg_version,
maintenance_version: maintenance,
from_dxf: false,
graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()),
payload: data.payload.clone(),
text_payload,
object_ids: data.object_ids.clone(),
})
}
_ => return Ok(None),
};
Ok(Some(Some(data)))
}))
.unwrap_or_else(|_| Err("application record conversion failed".into()));
match converted {
Ok(Some(Some(data))) => {
if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) {
entity.data = data;
}
}
Ok(Some(None)) => semantic = semantic.saturating_add(1),
Ok(None) => {}
Err(why) => {
doc.remove_entity(handle);
entity_count = entity_count.saturating_add(1);
skipped_note(&mut notes, "entity", handle, &why);
}
}
}
doc.objects.retain(|handle, object| {
if let ObjectType::ProxyObject(proxy) = object {
match remap(proxy.class_id) {
Ok(id) => proxy.class_id = id,
Err(why) => {
object_count = object_count.saturating_add(1);
skipped_note(&mut notes, "object", *handle, &why);
return false;
}
}
}
true
});
if entity_count > 0 || object_count > 0 {
notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})"));
}
if semantic > 0 {
notes.push(format!(
"DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it"
));
}
notes
}
fn skipped_note(notes: &mut Vec<String>, kind: &str, handle: acadrust::types::Handle, why: &str) {
if notes.len() < 6 {
let why: String = why.chars().take(256).collect();
notes.push(format!("DWG: proxy {kind} {handle} left out: {why}"));
}
}
/// The decoder's registered-class representation omits the separate R2007+ string stream.
fn registered_text(raw: &acadrust::entities::RawRecord) -> Result<ProxyPayload, String> {
use acadrust::io::dwg::DwgVersion;
use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader;
use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter;
use acadrust::types::DxfVersion;
if raw.version < DxfVersion::AC1021 {
return Ok(ProxyPayload::default());
}
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?;
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?;
let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?;
if handle_bits > total_bits {
return Err("DWG proxy text: handle stream exceeds record".into());
}
// Frame the verbatim record for the dependency's checked object reader.
let mut frame = DwgBitWriter::new(version, raw.version);
frame.write_modular_short(raw.data.len());
if raw.version >= DxfVersion::AC1024 {
frame.write_modular_char(handle_bits);
}
frame.write_bytes(&raw.data);
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?;
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?;
reader.read_common_entity_data(&mut streams, kind);
if kind == 498 {
// An existing registered-class envelope is a proxy and carries its class name first.
streams.read_variable_text();
} else if kind < 500 {
return Err("DWG proxy text: registered entity has no application class".into());
}
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?;
if bits > total_bits {
return Err("DWG proxy text: string stream exceeds record".into());
}
let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?;
let mut bytes = vec![0; bits.div_ceil(8)];
for (index, byte) in bytes.iter_mut().enumerate() {
for bit in 0..8.min(bits.saturating_sub(index * 8)) {
if streams.read_text_bit() {
*byte |= 0x80 >> bit;
}
}
}
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
Ok(ProxyPayload::from_bits(&bytes, count))
}
/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed
/// pair yields `None`, so both class scans fall back rather than guessing its identity.
pub(super) fn text_pairs(dxf: &[u8]) -> Option<impl Iterator<Item = Option<(i32, String)>> + '_> {
use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page};
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let version =
super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string);
let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021);
let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) {
super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page)
} else {
None
};
let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line));
Some(std::iter::from_fn(move || {
let code = lines.next()?;
let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) };
let pair = || {
let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?;
let text = if let Some(encoding) = encoding {
encoding.decode(value).0.into_owned()
} else if unicode {
String::from_utf8_lossy(value).into_owned()
} else {
// The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared.
std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string)
};
let text = decode_legacy_escapes(&text);
// DXF's escaped control characters are decoded after CIF/MIF, as in the bridge.
let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^");
Some((code, text))
};
Some(pair())
}))
}
/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF
/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (CADCraft's own
/// bridge DXF is ASCII), and for one with a record that can't be written back as it is: no names,
/// flags or instance count out of range, more classes than the numbers allow. Then
/// `proxy_classes` matches the classes by name instead.
pub(super) fn source_classes(dxf: &[u8]) -> Option<Vec<DxfClass>> {
let pairs = text_pairs(dxf)?;
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
let (mut section, mut in_classes) = (false, false);
for pair in pairs {
let (code, value) = pair?;
match (code, value.as_str()) {
(0, "ENDSEC") if in_classes => break,
(0, "SECTION") => section = true,
(2, name) if section => {
in_classes = name == "CLASSES";
section = false;
}
(0, kind) if in_classes => {
if kind != "CLASS" {
return None;
}
records.push(Vec::new());
}
(code, value) if in_classes => records.last_mut()?.push((code, value.to_string())),
_ => section = false,
}
}
records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect()
}
/// One CLASS record's groups as the class `number`, or `None` when it can't be written back.
fn source_class(groups: &[(i32, String)], number: i16) -> Option<DxfClass> {
let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default();
let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::<i64>().ok());
let mut class = DxfClass::new(text(1), text(2));
if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() {
return None;
}
class.application_name = text(3);
class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?);
class.instance_count = i32::try_from(integer(91)?).ok()?;
class.was_zombie = integer(280)? != 0;
class.is_an_entity = integer(281)? != 0;
class.item_class_id = if class.is_an_entity { 498 } else { 499 };
class.class_number = number;
Some(class)
}
/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in
/// their places, with their metadata and duplicate names, so each proxy's DXF class number names
/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number
/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and
/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing
/// changed, when the classes would be more than the class numbers allow.
pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec<DxfClass>) -> Option<(usize, usize)> {
let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len());
let mut any = false;
let mut entities = Vec::new();
for entity in doc.entities() {
if let EntityType::Extended(e) = entity
&& let ExtendedEntityData::Proxy(p) = &e.data
{
any = true;
if !fits(p.class_id) {
entities.push(e.common.handle);
}
}
}
let mut objects = Vec::new();
for (handle, object) in &doc.objects {
if let ObjectType::ProxyObject(p) = object {
any = true;
if !fits(p.class_id) {
objects.push(*handle);
}
}
}
if !any {
return Some((0, 0));
}
let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect();
let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect();
if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) {
class_number(last).ok()?;
}
for handle in &entities {
doc.remove_entity(*handle);
}
for handle in &objects {
doc.objects.remove(handle);
}
doc.classes.clear();
for (index, mut class) in classes.into_iter().chain(defaults).enumerate() {
class.class_number = class_number(index).ok()?;
doc.classes.push_preserving(class);
}
Some((objects.len(), entities.len()))
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData};
use acadrust::types::DxfVersion;
fn proxy_doc() -> CadDocument {
let mut doc = CadDocument::new();
doc.version = DxfVersion::AC1032;
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::Proxy(ProxyEntityData {
proxy_id: 498,
class_id: 501,
dxf_subclass: String::new(),
version: 33,
dwg_version: 33,
maintenance_version: 0,
from_dxf: false,
graphics: ProxyPayload::default(),
payload: ProxyPayload::from_bits(&[0x80], 1),
text_payload: ProxyPayload::default(),
object_ids: vec![],
}),
})))
.unwrap();
doc
}
/// An ASCII DXF of `groups`, `(code, value)` per pair of lines.
fn dxf(groups: &[(i32, &str)]) -> Vec<u8> {
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
}
#[test]
fn source_classes_keep_duplicates_and_metadata_before_defaults() {
let bytes = dxf(&[
(0, "SECTION"),
(2, "HEADER"),
(9, "$DIMSTYLE"),
(2, "CLASSES"),
(0, "ENDSEC"),
(0, "SECTION"),
(2, "CLASSES"),
(0, "CLASS"),
(1, "Объект"),
(2, "First"),
(3, "Первый"),
(90, "1025"),
(91, "7"),
(280, "1"),
(281, "1"),
(0, "CLASS"),
(1, "Объект"),
(2, "Second"),
(3, "Второй"),
(90, "128"),
(281, "1"),
(0, "ENDSEC"),
(0, "EOF"),
]);
let classes = source_classes(&bytes).unwrap();
assert_eq!(classes.len(), 2);
assert_eq!(classes[0].application_name, "Первый");
assert_eq!(classes[0].proxy_flags.0, 1025);
assert_eq!(classes[0].instance_count, 7);
assert!(classes[0].was_zombie);
assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501));
assert_eq!(classes[1].cpp_class_name, "Second");
let mut doc = proxy_doc();
assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0)));
assert_eq!(doc.classes.iter().take(2).cloned().collect::<Vec<_>>(), classes);
// A binary DXF, and a CLASS record that can't be written back as it is, give no classes.
assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none());
for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] {
let mut class = vec![(1, "A"), (2, "A")];
class.retain(|(c, _)| *c != code);
class.push((code, value));
let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")];
groups.extend(class);
groups.extend([(0, "ENDSEC"), (0, "EOF")]);
assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}");
}
}
#[test]
fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() {
let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n";
let text = format!("0\nSECTION\n2\nCLASSES\n{body}");
let classes = source_classes(text.as_bytes()).unwrap();
assert_eq!(classes[0].dxf_name, "Объект");
assert_eq!(classes[0].application_name, "Café🙂\\U+0041");
let prefix =
b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n";
let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat();
assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример");
// R2007+ text is UTF-8 even with a legacy code-page declaration.
let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n";
assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример");
}
#[test]
fn proxies_of_missing_classes_are_left_out() {
for id in [-1, 499, 501, i32::MAX] {
let mut doc = proxy_doc();
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
&& let ExtendedEntityData::Proxy(p) = &mut e.data
{
p.class_id = id;
}
let mut class = DxfClass::new_entity("ENTITY", "Entity");
class.class_number = 500;
assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}");
assert_eq!(doc.entities().count(), 0);
}
// Class 501 is there: the proxy keeps it, in its place.
let mut doc = proxy_doc();
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0)));
assert_eq!(doc.entities().count(), 1);
assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501)));
// Without proxies the classes stay the decoder's.
let mut doc = CadDocument::new();
let before: Vec<_> = doc.classes.iter().cloned().collect();
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0)));
assert_eq!(doc.classes.iter().cloned().collect::<Vec<_>>(), before);
}
#[test]
fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() {
use acadrust::entities::{Line, RawRecord};
use acadrust::objects::ProxyObject;
use acadrust::types::{Handle, Vector3};
for count in [1, 10] {
let mut doc = proxy_doc();
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
&& let ExtendedEntityData::Proxy(p) = &mut e.data
{
p.class_id = i32::MAX;
}
let template = doc.entities().next().unwrap().clone();
for _ in 1..count {
let mut entity = template.clone();
entity.common_mut().handle = Handle::NULL;
doc.add_entity(entity).unwrap();
}
let object = Handle::new(u64::MAX - 1);
assert!(!doc.objects.contains_key(&object));
doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() }));
let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap();
let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 };
doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
let notes = prepare(&mut doc);
assert_eq!(notes.len(), (count + 1).min(6) + 1);
assert!(notes.iter().all(|n| n.starts_with("DWG:")));
assert!(notes[0].contains(&format!("entities: {count}, objects: 1")));
assert_eq!(doc.entities().count(), 1);
assert!(!doc.objects.contains_key(&object));
let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") };
assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0));
assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0));
assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw));
}
}
#[test]
fn opaque_registered_class_ambiguity_is_not_guessed() {
let mut doc = CadDocument::new();
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
dxf_name: "CUSTOM".into(),
cpp_class_name: "Custom".into(),
properties: vec![],
payload: ProxyPayload::from_bits(&[0x80], 1),
object_ids: vec![],
}),
})))
.unwrap();
assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous")));
assert_eq!(doc.entities().count(), 0);
assert!(class_number(usize::MAX).is_err());
}
#[test]
fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() {
use acadrust::entities::RawRecord;
use acadrust::objects::{SemanticProperty, SemanticPropertyValue};
for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] {
let mut doc = CadDocument::new();
let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 };
let handle = doc
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
dxf_name: "SEMANTIC".into(),
cpp_class_name: "Semantic".into(),
properties: vec![SemanticProperty {
subclass: "Semantic".into(),
code: 1,
value: SemanticPropertyValue::Text("value".into()),
}],
payload: payload.clone(),
object_ids: vec![],
}),
})))
.unwrap();
doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
if payload.bit_count == 0 {
assert!(prepare(&mut doc).is_empty());
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
} else {
// Kept as read, and named: its opaque data has no DXF form beside the properties.
assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties")));
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
}
}
}
}
#[cfg(test)]
mod string_stream_tests {
use super::*;
use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData};
use acadrust::types::DxfVersion;
use std::io::Cursor;
#[test]
fn registered_transport_recovers_separate_non_byte_aligned_strings() {
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
let mut source = CadDocument::new();
source.version = version;
source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText"));
let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number);
let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19);
let payload = ProxyPayload::from_bits(&[0x80], 1);
source
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
common: EntityCommon::default(),
data: ExtendedEntityData::Proxy(ProxyEntityData {
proxy_id: 498,
class_id,
dxf_subclass: "CustomText".into(),
version: 33,
dwg_version: 33,
maintenance_version: 0,
from_dxf: false,
graphics: ProxyPayload::default(),
payload: payload.clone(),
text_payload: text.clone(),
object_ids: vec![],
}),
})))
.unwrap();
let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap();
let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
let entity = decoded.entities().next().unwrap();
let handle = entity.common().handle;
let raw = entity.common().raw_record.clone().unwrap();
assert_eq!(registered_text(&raw).unwrap().data(), text.data());
assert_eq!(registered_text(&raw).unwrap().bit_count, 19);
let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() };
entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
dxf_name: "CUSTOM_TEXT".into(),
cpp_class_name: "CustomText".into(),
properties: vec![],
payload: payload.clone(),
object_ids: vec![],
});
entity.common.raw_record = Some(raw);
assert!(prepare(&mut decoded).is_empty());
for _ in 0..2 {
let mut dxf = Vec::new();
acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap();
let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap();
decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
let p = decoded
.entities()
.find_map(|e| match e {
EntityType::Extended(e) => match &e.data {
ExtendedEntityData::Proxy(p) => Some(p),
_ => None,
},
_ => None,
})
.unwrap();
assert_eq!(p.payload.bit_count, 1);
assert_eq!(p.payload.data(), payload.data());
assert_eq!(p.text_payload.bit_count, 19);
assert_eq!(p.text_payload.data(), text.data());
}
}
}
#[test]
fn invalid_string_stream_boundaries_return_errors() {
for handle_bits in [-1, 1000] {
let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
assert!(registered_text(&raw).is_err());
}
}
}
+378
View File
@@ -0,0 +1,378 @@
//! The DWG reader and writer in a child process.
//!
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
//! that reads one request from standard input and writes one reply to standard output. A file that
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
//!
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
//!
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
//! untrusted input.
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::Duration;
use super::Limits;
/// The argument that makes the app a worker.
pub const WORKER_ARG: &str = "--dwg-worker";
/// Bumped when the messages change: an app updated while running may start a newer copy.
const PROTOCOL: u8 = 2;
const DECODE: u8 = 1;
const ENCODE: u8 = 2;
const CRASH: u8 = 0xff;
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Most bytes accepted for what a reply carries besides the drawing.
const MAX_NOTES_BYTES: u64 = 64 << 20;
/// Most bytes accepted as a written DWG.
const MAX_DWG_REPLY: u64 = 2 << 30;
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
const MAX_REQUEST: u64 = (2 << 30) + 256;
struct Worker {
program: PathBuf,
args: Vec<OsString>,
}
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
if let Ok(mut w) = WORKER.lock() {
*w = Some(Worker { program, args });
}
}
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
/// found, the reader and writer stay in this process.
pub fn use_own_worker() {
match std::env::current_exe() {
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
}
}
/// Serve one request and return the exit code if this process was started as a worker.
pub fn serve_if_asked() -> Option<i32> {
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
}
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
// Before starting a process, and so the same limit holds with or without one.
if bytes.len() > limits.max_dwg_bytes {
return super::dwg_to_dxf_noted(bytes, limits);
}
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
Some(reply) => reply?,
None => super::dwg_to_dxf_noted(bytes, limits),
}
}
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
encode_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
encode_kept_noted(dxf, version, None)
}
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
// A kept version too long to send is no version CADCraft knows: it changes nothing.
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
let mut header = request(ENCODE, Limits::DEFAULT, version);
push_str(&mut header, kept.unwrap_or(""));
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
None => encode_here(dxf, version, kept),
}
}
/// The conversion in this process.
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
match kept {
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
None => super::dxf_to_dwg_noted(dxf, version),
}
}
fn unwritten_from(notes: &[String]) -> super::Unwritten {
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
super::Unwritten::new(),
|mut m, (kind, n)| {
*m.entry(kind).or_insert(0) = n;
m
},
)
}
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
#[doc(hidden)]
pub fn crash_worker_for_test() -> Result<(), String> {
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
}
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
let mut r = vec![PROTOCOL, op];
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
push_str(&mut r, version);
r
}
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
fn push_str(r: &mut Vec<u8>, text: &str) {
r.push(u8::try_from(text.len()).unwrap_or(0));
r.extend(text.as_bytes());
}
type Reply = Result<(Vec<u8>, Vec<String>), String>;
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
let mut command = {
let worker = WORKER.lock().ok()?;
let worker = worker.as_ref()?;
let mut c = Command::new(&worker.program);
c.args(&worker.args);
c
};
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
log::warn!("DWG worker: can't start it: {e}");
return None;
}
};
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let received = std::thread::scope(|scope| {
// Written beside the read: a worker that replies before it has read everything, or dies,
// must block neither side. A failed write shows up as the reply going wrong.
scope.spawn(move || {
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
});
let (tx, rx) = channel();
scope.spawn(move || {
let mut reply = Vec::new();
let read = from_child.take(reply_limit).read_to_end(&mut reply);
let _ = tx.send(read.map(|_| reply));
});
let received = rx.recv_timeout(TIMEOUT);
if matches!(received, Err(RecvTimeoutError::Timeout)) {
let _ = child.kill();
}
received
});
let status = child.wait();
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
Some(match (received, status) {
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
_ => Err(stopped()),
})
}
/// Take `n` bytes off the front of `b`.
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
let (head, tail) = b.split_at_checked(n)?;
*b = tail;
Some(head)
}
fn take_u32(b: &mut &[u8]) -> Option<usize> {
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
usize::try_from(u32::from_le_bytes(bytes)).ok()
}
fn take_u64(b: &mut &[u8]) -> Option<u64> {
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
}
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
/// of the memory the app may use: they are kept, not copied.
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
let total = reply.len();
let mut b = &reply[..];
match *take(&mut b, 1)?.first()? {
0 => {
let count = take_u32(&mut b)?;
let mut notes = Vec::new();
for _ in 0..count {
let n = take_u32(&mut b)?;
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
}
let n = usize::try_from(take_u64(&mut b)?).ok()?;
if b.len() != n {
return None;
}
reply.drain(..total - n);
Some(Ok((reply, notes)))
}
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
_ => None,
}
}
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
match reply {
Ok((bytes, notes)) => {
out.write_all(&[0])?;
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
for note in &notes {
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
if u32::try_from(note.len()).is_ok() {
out.write_all(note.as_bytes())?;
}
}
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
out.write_all(&bytes)?;
}
Err(e) => {
out.write_all(&[1])?;
out.write_all(e.as_bytes())?;
}
}
out.flush()
}
/// What a request asks for, or why it can't be served.
fn handle(mut b: &[u8]) -> Reply {
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
return Err(bad());
}
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
match op {
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
ENCODE => {
// The version the DXF's carried-over groups were read from; empty for none.
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
encode_here(b, version, (!kept.is_empty()).then_some(kept))
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
}
CRASH => std::process::abort(),
_ => Err(bad()),
}
}
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
fn serve(input: impl Read, mut output: impl Write) -> i32 {
let mut request = Vec::new();
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
Ok(_) => handle(&request),
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
};
i32::from(write_reply(&mut output, reply).is_err())
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(reply: Reply) -> Vec<u8> {
let mut out = Vec::new();
write_reply(&mut out, reply).unwrap();
out
}
#[test]
fn what_the_writer_left_out_survives_the_reply() {
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
assert_eq!(unwritten_from(&notes), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
}
#[test]
fn replies_round_trip() {
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
let failed: Reply = Err("DWG: no".into());
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
}
#[test]
fn a_reply_cut_short_or_made_up_is_refused() {
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
for n in 0..whole.len() {
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
}
assert_eq!(parse_reply(vec![7, 0, 0]), None);
// A note count far beyond what follows ends at the first missing note.
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
}
#[test]
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
for request in [
&[][..],
&[PROTOCOL + 1, DECODE],
&[PROTOCOL],
&[PROTOCOL, DECODE, 1, 2, 3],
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
] {
assert!(handle(request).is_err(), "{request:?}");
}
}
/// An encode request carries the kept version after the target one; a request without it is
/// one it can't read, and one with it reaches the converter.
#[test]
fn an_encode_request_carries_the_kept_version() {
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
assert_eq!(handle(&short).unwrap_err(), bad);
for kept in ["", "AC1032"] {
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
push_str(&mut request, kept);
request.extend(b"not a drawing");
let err = handle(&request).unwrap_err();
assert_ne!(err, bad, "kept {kept:?}");
}
}
#[test]
fn serving_reports_a_decode_failure_as_a_reply() {
let mut request = self::request(DECODE, Limits::DEFAULT, "");
request.extend(b"AC1032 not really a drawing");
let mut out = Vec::new();
assert_eq!(serve(&request[..], &mut out), 0);
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
}
}
+14
View File
@@ -0,0 +1,14 @@
[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
@@ -0,0 +1,398 @@
//! 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
@@ -0,0 +1,925 @@
//! 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
@@ -0,0 +1,117 @@
//! The DXF/DWG file versions CADCraft writes (DXF Reference, HEADER `$ACADVER`).
/// A file version CADCraft can save: the AutoCAD release that introduced the format. Each format
/// also serves the releases after it (2018 is still AutoCAD's current format).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Version {
/// AutoCAD 2000/2002 (AC1015).
R2000,
/// AutoCAD 2004–2006 (AC1018).
R2004,
/// AutoCAD 2007–2009 (AC1021): the first version whose DXF text is UTF-8.
R2007,
/// AutoCAD 2010–2012 (AC1024).
R2010,
/// AutoCAD 2013–2017 (AC1027).
R2013,
/// AutoCAD 2018 and later (AC1032), the default.
#[default]
R2018,
}
impl Version {
/// Every version, oldest first.
pub const ALL: [Version; 6] = [Version::R2000, Version::R2004, Version::R2007, Version::R2010, Version::R2013, Version::R2018];
/// The `$ACADVER` code ("AC1032").
pub fn code(self) -> &'static str {
match self {
Version::R2000 => "AC1015",
Version::R2004 => "AC1018",
Version::R2007 => "AC1021",
Version::R2010 => "AC1024",
Version::R2013 => "AC1027",
Version::R2018 => "AC1032",
}
}
/// The release year that names the version ("2018").
pub fn year(self) -> &'static str {
match self {
Version::R2000 => "2000",
Version::R2004 => "2004",
Version::R2007 => "2007",
Version::R2010 => "2010",
Version::R2013 => "2013",
Version::R2018 => "2018",
}
}
/// A version named by its year ("2018", "R2018") or its `$ACADVER` code ("AC1032"), any case.
pub fn parse(s: &str) -> Option<Version> {
let s = s.trim();
let year = s.strip_prefix(['R', 'r']).unwrap_or(s);
Version::ALL.into_iter().find(|v| v.year() == year || v.code().eq_ignore_ascii_case(s))
}
/// The version to save a file of `$ACADVER` `code` in: the same version when CADCraft writes
/// it, else the nearest one it does (R2000 for older files, 2018 for newer ones); `None` when
/// the code isn't a version code at all.
pub fn for_acadver(code: &str) -> Option<Version> {
let code = code.trim();
if let Some(v) = Version::ALL.into_iter().find(|v| v.code().eq_ignore_ascii_case(code)) {
return Some(v);
}
code.get(..2).filter(|p| p.eq_ignore_ascii_case("AC"))?;
let n = code.get(2..).filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse::<u32>().ok())?;
Some(match n {
..1018 => Version::R2000,
1018..1021 => Version::R2004,
1021..1024 => Version::R2007,
1024..1027 => Version::R2010,
1027..1032 => Version::R2013,
_ => Version::R2018,
})
}
/// Whether text in this version's DXF is UTF-8 (2007 and later). Older versions keep text in
/// the `$DWGCODEPAGE` code page, which CADCraft avoids by escaping non-ASCII as `\U+XXXX`.
pub fn utf8(self) -> bool {
self >= Version::R2007
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.year())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_years_and_codes() {
assert_eq!(Version::parse("2018"), Some(Version::R2018));
assert_eq!(Version::parse(" r2004 "), Some(Version::R2004));
assert_eq!(Version::parse("ac1021"), Some(Version::R2007));
assert_eq!(Version::parse("2005"), None);
assert_eq!(Version::parse(""), None);
for v in Version::ALL {
assert_eq!(Version::parse(v.code()), Some(v));
assert_eq!(Version::parse(v.year()), Some(v));
}
}
#[test]
fn file_versions_map_to_the_nearest_writable_one() {
assert_eq!(Version::for_acadver("AC1009"), Some(Version::R2000));
assert_eq!(Version::for_acadver("AC1014"), Some(Version::R2000));
assert_eq!(Version::for_acadver("AC1024"), Some(Version::R2010));
assert_eq!(Version::for_acadver("AC1035"), Some(Version::R2018));
assert_eq!(Version::for_acadver("MC0.0"), None);
assert_eq!(Version::for_acadver("AC"), None);
assert_eq!(Version::for_acadver("AC99999999999999999999"), None);
}
}
+19
View File
@@ -0,0 +1,19 @@
//! 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
@@ -0,0 +1,75 @@
//! Binary DXF writing (#421): every value kind round-trips through `write_binary` and `parse`,
//! and hostile binary input never panics.
use cadcraft_dxf::{Tag, Value, is_binary, parse, write_ascii_as, write_binary};
fn sample() -> Vec<Tag> {
vec![
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "TEXT"),
Tag::s(5, "1F"),
Tag::f(10, -3.25),
Tag::i(62, 5),
Tag::i(90, 70_000),
Tag::i(160, 1 << 40),
Tag::new(290, Value::Bool(true)),
Tag::new(310, Value::Hex("00FFA5".into())),
Tag::s(1, "图号 Café 😀"),
Tag::i(1071, -2),
Tag::s(0, "ENDSEC"),
Tag::s(0, "EOF"),
]
}
#[test]
fn binary_roundtrips_every_value_kind() {
for utf8 in [false, true] {
let bytes = write_binary(&sample(), utf8);
assert!(is_binary(&bytes));
assert_eq!(parse(&bytes).unwrap(), sample(), "utf8 = {utf8}");
}
// Pre-2007 strings are escaped to ASCII, 2007+ ones are UTF-8.
let old = write_binary(&sample(), false);
assert!(old.windows(7).any(|w| w == b"\\U+56FE"));
let new = write_binary(&sample(), true);
assert!(new.windows("图号".len()).any(|w| w == "图号".as_bytes()));
}
#[test]
fn binary_converts_values_to_their_codes_kind() {
// A number held as text at a numeric code, an out-of-range 16-bit value, a long chunk.
let long = "AB".repeat(300);
let tags = vec![Tag::s(70, "12"), Tag::i(62, 1 << 20), Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")];
let back = parse(&write_binary(&tags, true)).unwrap();
assert_eq!(back[0], Tag::i(70, 12));
assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX)));
assert_eq!(back[2].code, 310);
assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255");
assert_eq!(back[2].str().len() + back[3].str().len(), 600);
}
#[test]
fn utf8_ascii_keeps_text_and_drops_line_breaks() {
let text = write_ascii_as(&[Tag::s(1, "图号\r\nÄ"), Tag::s(0, "EOF")], true);
assert!(text.contains("图号 Ä\r\n"));
assert_eq!(parse(text.as_bytes()).unwrap()[0].str(), "图号 Ä");
}
#[test]
fn hostile_binary_input_never_panics() {
let good = write_binary(&sample(), true);
// Every truncation, and every single-byte corruption of the body.
for n in 0..good.len() {
let _ = parse(good.get(..n).unwrap());
}
for i in 22..good.len() {
for b in [0u8, 0x7f, 0xff] {
let mut bad = good.clone();
bad[i] = b;
let _ = parse(&bad);
}
}
// A sentinel and nothing else is not a drawing.
assert!(parse(b"AutoCAD Binary DXF\r\n\x1a\0").is_err());
}
+116
View File
@@ -0,0 +1,116 @@
//! 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
@@ -0,0 +1,145 @@
//! 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
@@ -0,0 +1,220 @@
//! 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
@@ -0,0 +1,119 @@
//! 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
@@ -0,0 +1,148 @@
//! 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 }
craft-dxf = { workspace = true }
cadcraft-dxf = { workspace = true }
cadcraft-constraints = { workspace = true }
cadcraft-fonts = { workspace = true }
cadcraft-render = { workspace = true }
+1 -85
View File
@@ -16,31 +16,10 @@ 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;
@@ -54,15 +33,13 @@ 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_locked(path, permissions, |file| file.write_all(bytes))
write_atomic_with(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
@@ -90,30 +67,10 @@ 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)?;
@@ -133,20 +90,6 @@ fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill:
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.
@@ -288,33 +231,6 @@ 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() {
+12 -9
View File
@@ -144,10 +144,10 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When saving a copy, the source keeps its last saved definition. The editor's own
// table entry can still be the one from before BSAVE; the copy gets the edited data.
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
if !name.eq_ignore_ascii_case(&self.name) {
restore_saved_definition(&mut d, host, &self.name);
keep_description(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
@@ -176,13 +176,16 @@ impl BlockEdit {
}
}
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
/// Without a saved source, drop the empty definition an editor description may have created.
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
d.blocks.insert(key, h.clone());
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
@@ -299,7 +302,7 @@ impl DocState {
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
restore_saved_definition(editor, &d, &was);
keep_description(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub(super) fn blockreplace_spec() -> CommandSpec {
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
.menu(&["Tools", "Additional Tools", "Replace Block..."])
.menu(&["Tools", "Express Tools", "Replace Block..."])
.alias(&["-blockreplace"])
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{Result, Session};
pub(super) fn burst_spec() -> CommandSpec {
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
.menu(&["Tools", "Additional Tools", "Explode Attributes to Text"])
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
.params("{handles?} (block references; other objects are left alone) → {handles}")
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
}
+1 -56
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 *), 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)}")
.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)}")
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
CommandSpec::new("xbind", "Bind...", run_xbind)
.menu(&["Modify", "Object", "External Reference", "Bind..."])
@@ -201,46 +201,6 @@ 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(),
@@ -263,19 +223,6 @@ 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);
@@ -305,7 +252,6 @@ 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);
@@ -330,7 +276,6 @@ 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) {
+1 -17
View File
@@ -313,23 +313,7 @@ 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 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()
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
}
impl Ctx {
+1 -1
View File
@@ -20,7 +20,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
.menu(&["Tools", "Additional Tools", "Break-line Symbol"])
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
.params(
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
+17 -101
View File
@@ -8,7 +8,7 @@ mod template;
use std::sync::OnceLock;
use cadcraft_doc::{Drawing, Format};
pub use craft_dxf::Version;
pub use cadcraft_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<craft_dxf::Tag>),
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
/// An entity from a type name and groups.
pub entity: fn(&str, &[craft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
pub entity: fn(&str, &[cadcraft_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: craft_dxf::is_binary(bytes), name: name.to_string(), path })
Ok(ReadFile { drawing, binary_dxf: cadcraft_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 || craft_dxf::is_binary(bytes) {
if dwg || cadcraft_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 craft_dxf::is_binary(head) {
if cadcraft_dxf::is_binary(head) {
return Some(tail.ends_with(b"EOF\0"));
}
let text = String::from_utf8_lossy(tail);
@@ -625,40 +625,6 @@ 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
@@ -682,23 +648,19 @@ 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 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()))))))
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))
};
#[cfg(not(unix))]
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)?;
}
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
@@ -1069,52 +1031,6 @@ 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()
}
+5 -22
View File
@@ -43,8 +43,7 @@ pub(crate) struct GripEdit {
hot: Vec<(Handle, usize)>,
/// Typed `GRIP`: still collecting grips ("Select grips").
picking: bool,
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
/// selection that the drawing shows.
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection.
targets: Vec<Handle>,
base: Vec2,
mode: GripMode,
@@ -70,12 +69,6 @@ fn positive(v: Option<f64>) -> Result<f64> {
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
}
/// Whether the drawing shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer) takes no
/// part in a grip edit, even when Undo or a selection command put it back into the selection.
fn shown(s: &Session, h: Handle) -> bool {
s.doc().is_ok_and(|d| d.entity(h).is_some_and(|e| d.is_visible(e)))
}
impl GripEdit {
fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result<Self> {
let mut g = GripEdit {
@@ -94,21 +87,16 @@ impl GripEdit {
Ok(g)
}
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
/// the drawing does not show is left out, and so are the hidden objects of the selection.
/// Make grip `index` of `h` hot (the first one becomes the base point).
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
return Ok(());
}
let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?;
// After the lookup: a missing object or grip stays the error it was.
if !shown(s, h) {
return Ok(());
}
if self.hot.is_empty() {
self.base = p;
self.targets.push(h);
self.targets.extend(s.selection().into_iter().filter(|x| *x != h && shown(s, *x)));
self.targets.extend(s.selection().into_iter().filter(|x| *x != h));
} else if !self.targets.contains(&h) {
self.targets.push(h);
}
@@ -125,7 +113,7 @@ impl GripEdit {
if super::curves::is_locked(s, h) {
continue;
}
let Some(e) = d.entity(h).filter(|e| d.is_visible(e)) else { continue };
let Some(e) = d.entity(h) else { continue };
let gs = e.kind.grips();
// The nearest grip of this object within the tolerance.
let best = gs
@@ -482,9 +470,7 @@ impl Interactive for GripEdit {
impl Session {
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
/// modes and their options then run at the command line like any prompt. Grips of objects the
/// drawing does not show are left out (the first grip left is the base grip), and it is an
/// error when none is left.
/// modes and their options then run at the command line like any prompt.
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
if self.running.is_some() {
self.cancel();
@@ -493,9 +479,6 @@ impl Session {
return Err(other("no grip"));
}
let g = GripEdit::new(self, hot.to_vec())?;
if g.hot.is_empty() {
return Err(other("no grip"));
}
let label = g.label();
let machine = crate::guard::Guarded::wrap(Box::new(g));
let st = self.state()?;
+4 -52
View File
@@ -26,30 +26,11 @@ 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 {
// 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 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,
}
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.
@@ -152,32 +133,3 @@ 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"));
}
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
.interactive(|_| Ok(Box::new(NcopyM::default()))),
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
.menu(&["Tools", "Additional Tools", "Move/Copy/Rotate"])
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
CommandSpec::new("chspace", "Change Space", run_chspace)
+2 -3
View File
@@ -108,9 +108,8 @@ 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 {
// 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();
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
let t: Vec<char> = text.to_lowercase().chars().take(4096).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;
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
.menu(&["Tools", "Additional Tools", "Convert Text to Mtext"])
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
.params(
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
+4 -47
View File
@@ -27,14 +27,8 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
if !writing {
match read_text_file(&path) {
Some(t) => {
// 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));
charge_bytes(t.len())?;
f.lines = t.lines().map(str::to_string).collect();
}
None => return Ok(Flow::Value(Value::Nil)),
}
@@ -106,19 +100,8 @@ 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;
// 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 = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
if let Ok(mut f) = f {
let _ = f.write_all(text.as_bytes());
}
}
@@ -135,29 +118,3 @@ 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 {
craft_dxf::Value::Real(r) => r,
craft_dxf::Value::Int(i) => i as f64,
cadcraft_dxf::Value::Real(r) => r,
cadcraft_dxf::Value::Int(i) => i as f64,
_ => continue,
};
t.value = craft_dxf::Value::Real(f(v));
t.value = cadcraft_dxf::Value::Real(f(v));
}
}
fn tag_value(t: &Tag) -> Value {
match &t.value {
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)),
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)),
}
}
-10
View File
@@ -1551,13 +1551,3 @@ fn setvar_rejects_non_finite_text_for_real_variables() {
}
}
}
#[test]
fn additional_tools_keeps_compatible_command_ids() {
assert_eq!(crate::cmd::resolve_alias("-blockreplace"), "blockreplace");
for id in ["blockreplace", "burst", "breakline", "mocoro", "txt2mtxt"] {
let command = crate::cmd::find_command(id).expect("existing command id");
assert!(command.menu.contains(&"Additional Tools"), "{id}: {:?}", command.menu);
assert!(!command.menu.contains(&"Express Tools"));
}
}
-37
View File
@@ -1,37 +0,0 @@
//! Saving an edited block as a copy must retain its source's last saved definition.
use cadcraft_engine::Session;
use serde_json::json;
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
/// source block keeps the definition of its own last save.
#[test]
fn save_as_preserves_the_last_saved_source_definition() {
let mut s = Session::new();
let mut handles = Vec::new();
for y in [5, 9] {
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
handles.push(line["handles"][0].clone());
}
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
assert_eq!(saved_source.entities.len(), 3);
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
// A later save of Copy must not reintroduce the editor table's older source definition.
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
}
@@ -1,161 +0,0 @@
//! A hidden object that is back in the selection takes no part in a grip edit: Undo, `sssetfirst`
//! or a control call can restore it there, and the grips it shares with a visible object must not
//! drag it along.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use cadcraft_engine::geom::Vec2;
use serde_json::json;
/// Draws a line from `a` to `b` and returns its handle.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// The two ends of the line `h`.
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
k => panic!("not a line: {k:?}"),
}
}
/// Two lines sharing the end (0, 0), the second one hidden, both in the selection.
fn shared_corner() -> (Session, Handle, Handle) {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
s.execute("hideobjects", &json!({ "handles": [hidden.hex()] })).unwrap();
let d = s.doc().unwrap();
assert!(!d.is_visible(d.entity(hidden).unwrap()), "the second line must be hidden");
s.set_selection(vec![shown, hidden]);
(s, shown, hidden)
}
/// The index of the grip of `h` at `p`.
fn grip_at(s: &Session, h: Handle, p: Vec2) -> usize {
s.grips_of(h).unwrap().iter().position(|g| g.near(p, 1e-9)).unwrap()
}
/// Stretching the shared corner moves the visible line only; the hidden one stays where it was.
#[test]
fn a_hidden_selected_line_is_not_stretched_with_a_shared_grip() {
let (mut s, shown, hidden) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
/// The Move mode of a grip edit moves the visible selection only.
#[test]
fn a_hidden_selected_line_is_not_moved_by_the_move_mode_of_a_grip_edit() {
let (mut s, shown, hidden) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(10.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text());
s.cmdline("12,5").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(2.0, 5.0), 1e-9) && end.near(Vec2::new(12.0, 5.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
/// The grip of a hidden object is never made hot, so a grip edit cannot start on it.
#[test]
fn the_grip_of_a_hidden_object_cannot_be_made_hot() {
let (mut s, _, hidden) = shared_corner();
let before = s.state().unwrap().doc.clone();
let err = s.start_grip_edit(&[(hidden, 0)]).unwrap_err().to_string();
assert!(err.contains("no grip"), "{err}");
assert!(s.running.is_none());
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
}
/// A hidden object's grip among the hot grips is left out, wherever it stands in the list: the
/// first grip the drawing shows is the base grip, and only the visible line is stretched.
#[test]
fn a_hidden_grip_among_the_hot_grips_is_left_out() {
for hidden_first in [false, true] {
let (mut s, shown, hidden) = shared_corner();
let mut hot = vec![(shown, grip_at(&s, shown, Vec2::new(0.0, 0.0))), (hidden, grip_at(&s, hidden, Vec2::new(0.0, 10.0)))];
if hidden_first {
hot.reverse();
}
s.start_grip_edit(&hot).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "hidden first: {hidden_first}: {start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
"hidden first: {hidden_first}: the hidden line moved: {start:?} {end:?}"
);
}
}
/// Only what the drawing does not show is left out: a grip of an object that does not exist is
/// still an error, also after a good one.
#[test]
fn a_missing_object_among_the_hot_grips_is_still_an_error() {
let (mut s, shown, _) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
let err = s.start_grip_edit(&[(shown, i), (Handle(0xFFFF), 0)]).unwrap_err().to_string();
assert!(err.contains("no such object"), "{err}");
assert!(s.running.is_none());
}
/// A selected line on a layer that is off is not shown either, and is not stretched with a
/// shared grip.
#[test]
fn a_selected_line_on_a_layer_that_is_off_is_not_stretched_with_a_shared_grip() {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("layer.new", &json!({ "name": "Guides", "current": true })).unwrap();
let off = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
s.execute("layer.set", &json!({ "name": "Guides", "on": false })).unwrap();
let d = s.doc().unwrap();
assert!(!d.is_visible(d.entity(off).unwrap()), "the second line must be on a layer that is off");
s.set_selection(vec![shown, off]);
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, off);
assert!(
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
"the line on the layer that is off moved: {start:?} {end:?}"
);
}
/// The typed GRIP command finds no grip where only a hidden object has one, and at the shared
/// corner it makes only the visible line's grip hot.
#[test]
fn the_typed_grip_command_finds_no_grip_of_a_hidden_object() {
let (mut s, shown, hidden) = shared_corner();
s.cmdline("grip").unwrap();
assert!(s.prompt_text().contains("Select grip"), "{}", s.prompt_text());
// Only the hidden line has a grip at (0, 10): the command says so and asks again.
s.cmdline("0,10").unwrap();
assert!(s.log.iter().any(|l| l.contains("No grip of a selected object there")), "{:?}", s.log);
s.cmdline("0,0").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text());
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
+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 {
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)),
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)),
};
(t.code, v)
})
-1
View File
@@ -62,7 +62,6 @@ mod hatch_dialog_helpers;
mod hatch_numeric_validation;
mod hatch_options;
mod helix;
mod hidden_grip_targets;
mod inquiry_keywords;
mod inquiry_variables;
mod inquiry_z;
-22
View File
@@ -190,28 +190,6 @@ 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 }
craft-dxf = { workspace = true }
cadcraft-dxf = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
+3 -28
View File
@@ -43,16 +43,13 @@ 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()))?;
// 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 text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
for line in text.lines().map(str::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 {n}"));
return Err(format!("malformed manifest line: {line}"));
};
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
@@ -76,28 +73,6 @@ 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
+1 -10
View File
@@ -184,9 +184,6 @@ 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))
@@ -205,13 +202,7 @@ 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 = 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(),
};
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
if !contours.is_empty() {
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
}
+1 -55
View File
@@ -153,40 +153,6 @@ 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.
@@ -293,7 +259,7 @@ pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
continue;
}
} else if n == Some('M') && plus == Some('+') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && hex4(&chars, i + 4).is_some() {
push_literal(&mut out, craft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
push_literal(&mut out, cadcraft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
i += 8;
continue;
}
@@ -884,7 +850,6 @@ 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,
@@ -894,15 +859,7 @@ 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() {
@@ -939,17 +896,6 @@ 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,7 +677,6 @@ 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 };
@@ -697,15 +696,7 @@ 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>]>,
pub(crate) points: usize,
points: usize,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
+3 -3
View File
@@ -12,8 +12,8 @@ workspace = true
cadcraft-geom = { workspace = true }
cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
craft-dxf = { workspace = true }
craft-dwg = { workspace = true }
cadcraft-dxf = { workspace = true }
cadcraft-dwg = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-fonts = { workspace = true }
thiserror = { workspace = true }
@@ -29,7 +29,7 @@ tiny-skia = { workspace = true }
# Reads plotted PDFs back.
craft-pdf = { workspace = true, features = ["read"] }
# Inspect DWG output only where craft-dwg builds its native bridge. An unconditional
# Inspect DWG output only where cadcraft-dwg builds its native bridge. An unconditional
# dev-dependency also builds acadrust during --all-targets, breaking FreeBSD aarch64 (#2526).
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dev-dependencies]
acadrust = "0.6.6"
+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 craft_dxf::{Section, Tag};
use cadcraft_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 craft_dxf::{Kind, Value, kind_of};
use cadcraft_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 = craft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
let tags = cadcraft_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: &[craft_dxf::Section]) -> HashMap<String, String> {
pub(super) fn plot_style_names(secs: &[cadcraft_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 craft_dxf::Tag;
use cadcraft_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 {
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)),
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)),
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 craft_dxf::Tag;
use cadcraft_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 {
craft_dxf::write_ascii(&tags(d, Version::R2000))
cadcraft_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);
craft_dxf::write_ascii(&tags)
cadcraft_dxf::write_ascii(&tags)
}
/// Proxy type names and lookup-parameter subclass names must match the bridge reader's spelling.
+2 -5
View File
@@ -344,10 +344,7 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo
fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) {
if let EntityKind::Insert(i) = &e.kind {
w.s(0, "INSERT");
// A reference repeated in rows or columns (MINSERT, groups 70/71) is an AcDbMInsertBlock;
// readers that go by the marker draw only the first copy of an AcDbBlockReference.
let array = i.cols > 1 || i.rows > 1;
common(w, e, owner, paper, if array { "AcDbMInsertBlock" } else { "AcDbBlockReference" });
common(w, e, owner, paper, "AcDbBlockReference");
if !i.attribs.is_empty() {
w.i(66, 1);
}
@@ -357,7 +354,7 @@ fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: b
w.f(42, i.scale.y);
w.f(43, i.scale.z);
w.f(50, i.rotation.to_degrees());
if array {
if i.cols > 1 || i.rows > 1 {
w.i(70, i64::from(i.cols));
w.i(71, i64::from(i.rows));
w.f(44, i.col_spacing);
+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 && craft_dxf::decode_code_page(b"", read).is_some();
let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some();
if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() }
}
+15 -28
View File
@@ -31,7 +31,7 @@ use cadcraft_geom::Bounds2;
use std::path::{Path, PathBuf};
pub use cadcraft_doc::Format;
pub use craft_dxf::Version;
pub use cadcraft_dxf::Version;
pub use dxf_read::entity_from_groups;
pub use dxf_write::entity_groups;
pub use pdf::{PdfOptions, pdf, plot};
@@ -61,24 +61,11 @@ pub enum IoError {
pub type Result<T> = std::result::Result<T, IoError>;
/// Restore CADCraft's public diagnostics after the shared byte bridge supplies generic wording.
fn cadcraft_dwg_message(message: &str) -> String {
message
.replace("accepted by this bridge", "CADCraft opens")
.replace("types this bridge can't preserve", "types CADCraft doesn't keep")
.replace("this bridge writes", "CADCraft writes")
.replace("accepted for DWG conversion", "CADCraft converts to DWG")
}
fn dwg_error(message: String) -> IoError {
IoError::Format(cadcraft_dwg_message(&message))
}
/// How a program runs the DWG reader and writer in a child process (`craft_dwg::worker`).
/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`).
pub mod dwg_worker {
pub use craft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker};
#[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))]
pub use craft_dwg::{crash_worker_for_test, use_worker};
pub use cadcraft_dwg::{crash_worker_for_test, use_worker};
}
fn ext(name: impl AsRef<Path>) -> String {
@@ -94,10 +81,10 @@ pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files),
/// which the round-trip harness counts.
pub(crate) fn read_source(bytes: &[u8], name: &Path) -> Result<(Drawing, Option<Vec<u8>>)> {
if craft_dwg::is_dwg(bytes) {
let (dxf, notes) = craft_dwg::decode(bytes, craft_dwg::Limits::DEFAULT).map_err(dwg_error)?;
if cadcraft_dwg::is_dwg(bytes) {
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
d.read_notes = notes.into_iter().map(|note| cadcraft_dwg_message(&note)).collect();
d.read_notes = notes;
return Ok((d, Some(dxf)));
}
let d = match (Format::from_path(name), ext(name).as_str()) {
@@ -127,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) => 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),
(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),
})
}
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
@@ -145,21 +132,21 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many
/// records of each DXF type the DWG writer can't write (other programs' custom entities and
/// objects); for other formats, nothing.
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, craft_dwg::Unwritten)> {
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
let name = name.as_ref();
if matches!(Format::from_path(name), Some(Format::Dwg)) && !opts.binary {
return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004)));
}
write_with(d, name, opts).map(|b| (b, craft_dwg::Unwritten::new()))
write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new()))
}
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
/// there is one. The objects CADCraft keeps without modelling them are still in the groups of the
/// file they came from (`Foreign::version`, not `$ACADVER`, which SAVEAS changes), and the bridge
/// reads them as that version.
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, craft_dwg::Unwritten)> {
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
let bridge = dxf_write::dwg_bridge(d, version);
craft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(dwg_error)
cadcraft_dwg::encode_kept_noted(bridge.as_bytes(), version.code(), d.foreign.version.as_deref()).map_err(IoError::Format)
}
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
+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: &[craft_dxf::Tag]) -> bool {
fn is_overall_viewport(t: &str, g: &[cadcraft_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 = craft_dxf::parse(dxf).ok()?;
let tags = cadcraft_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: &[craft_dxf::Tag]| {
let mut note_refs = |t: &str, g: &[cadcraft_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 craft_dxf::sections(&tags) {
let records = craft_dxf::records(&s.tags);
for s in cadcraft_dxf::sections(&tags) {
let records = cadcraft_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(craft_dxf::Tag::str).unwrap_or_default();
let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default();
space = is_space_block(&name);
if !space {
blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new()));
+2 -2
View File
@@ -36,7 +36,7 @@ impl Format {
}
/// The format of a file's bytes: DWG by its magic, otherwise DXF.
pub fn of(bytes: &[u8]) -> Format {
if craft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
}
fn other(self) -> Format {
match self {
@@ -279,7 +279,7 @@ pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport
let format = Format::of(bytes);
let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() };
if format == Format::Dwg {
f.version = craft_dwg::version(bytes).unwrap_or_default();
f.version = cadcraft_dwg::version(bytes).unwrap_or_default();
}
let start = Instant::now();
let name = format!("source.{}", format.ext());
+30 -50
View File
@@ -797,7 +797,7 @@ fn landscape_media_keeps_its_size_order_and_rotation() {
assert_eq!(p.paper, media);
let dwg = write(&d, "t.dwg").unwrap();
let back = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
assert_eq!(plot_paper_groups(&back, "Layout1"), (420.0, 297.0, rotation), "DWG, landscape {landscape}");
let r = read(&dwg, "t.dwg").unwrap();
let p = &r.layout("Layout1").unwrap().page;
@@ -844,7 +844,7 @@ fn a_files_own_paper_groups_are_written_back() {
assert_eq!((p.width_mm, p.height_mm, p.landscape), (pw, ph, landscape), "{media} {groups:?} read");
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DXF");
let dwg = write(&r, "t.dwg").unwrap();
let back = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned();
assert_eq!(plot_paper_groups(&back, "Layout1"), groups, "{media} {groups:?} DWG");
let r = read(&dwg, "t.dwg").unwrap();
assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DWG read and saved again");
@@ -1234,7 +1234,7 @@ fn viewport_frozen_layers_survive_dwg() {
let back = read(&dwg, "t.dwg").unwrap();
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
// The DWG holds them in group 331, what AutoCAD reads.
let p = pairs(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
let p = pairs(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]);
}
@@ -1376,8 +1376,8 @@ fn generated_arrowhead_blocks_are_not_kept_as_user_blocks() {
for (_, name) in crate::dxf_ext::ARROWS.iter().filter(|(k, _)| *k != cadcraft_render::Arrowhead::ClosedFilled) {
assert!(dxf.contains(&format!("\r\n{name}\r\n")), "{name} is written");
}
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{what}: {:?}", back.blocks.keys().collect::<Vec<_>>());
assert!(roundtrip(&back).blocks.keys().all(|k| !k.starts_with('_')), "{what}: second save");
@@ -1408,8 +1408,8 @@ fn file_arrowhead_blocks_with_standard_names_are_kept() {
d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: "_Dot".into(), arrow_block2: "_DotSmall".into(), ..DimStyle::default() });
let kinds = |d: &Drawing, name: &str| d.block(name).map(|b| b.entities.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()).unwrap_or_default();
let dxf = write_dxf(&d);
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
for name in ["_Dot", "_DotSmall"] {
assert_eq!(kinds(&back, name), kinds(&d, name), "{what}: {name}");
@@ -1479,8 +1479,8 @@ fn many_entities(n: usize) -> Drawing {
#[cfg(not(target_arch = "wasm32"))]
fn ac1024_dwg(n: usize) -> Vec<u8> {
let dxf = write_dxf(&many_entities(n)).replacen("AC1015", "AC1024", 1);
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some("AC1024"));
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1024"));
dwg
}
@@ -1492,8 +1492,8 @@ fn ac1024_dwg(n: usize) -> Vec<u8> {
fn ac1024_dwg_with_many_entities_opens() {
let n = 30_000;
let dwg = ac1024_dwg(n);
let dxf = craft_dwg::dwg_to_dxf(&dwg).unwrap();
let tags = craft_dxf::parse(&dxf).unwrap().len();
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let tags = cadcraft_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();
@@ -1509,43 +1509,23 @@ fn ac1024_dwg_with_many_entities_opens() {
#[test]
fn oversized_dwg_and_dxf_are_rejected_early() {
let dwg = ac1024_dwg(3000);
let full = craft_dwg::dwg_to_dxf(&dwg).unwrap().len();
let full = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().len();
assert!(full > 400_000, "{full}");
let limits = craft_dwg::Limits { max_dxf_bytes: 300_000, ..craft_dwg::Limits::DEFAULT };
let e = craft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
let limits = cadcraft_dwg::Limits { max_dxf_bytes: 300_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: converting this 0."), "{e}");
assert!(e.contains("MB file produced more than 0.3 MB of drawing data (the limit)"), "{e}");
let limits = craft_dwg::Limits { max_dwg_bytes: 100_000, ..craft_dwg::Limits::DEFAULT };
let e = craft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB accepted by this bridge"), "{e}");
let limits = cadcraft_dwg::Limits { max_dwg_bytes: 100_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB CADCraft opens"), "{e}");
// 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 = craft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
let e = cadcraft_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)");
}
#[test]
fn shared_dwg_diagnostics_keep_cadcraft_vocabulary() {
assert_eq!(
cadcraft_dwg_message("DWG: the file is 0.1 MB, larger than the 0.1 MB accepted by this bridge"),
"DWG: the file is 0.1 MB, larger than the 0.1 MB CADCraft opens"
);
assert_eq!(
cadcraft_dwg_message("DWG: 2 objects of types this bridge can't preserve were left out (FOO × 2)"),
"DWG: 2 objects of types CADCraft doesn't keep were left out (FOO × 2)"
);
assert_eq!(
cadcraft_dwg_message("DWG version `AC1015` can't be written (this bridge writes AC1018+)"),
"DWG version `AC1015` can't be written (CADCraft writes AC1018+)"
);
assert_eq!(
cadcraft_dwg_message("DXF: the input is 1024 MB, larger than the 1024 MB accepted for DWG conversion"),
"DXF: the input is 1024 MB, larger than the 1024 MB CADCraft converts to DWG"
);
}
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
/// rest opens, and the drawing's read notes say how many records were left out and of which
/// types, so a partial read never passes for a complete one. A complete read has no notes.
@@ -1638,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 = craft_dxf::parse(text.as_bytes()).unwrap();
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
let mut stripped = Vec::new();
let mut skipping = false;
for t in tags {
@@ -1651,7 +1631,7 @@ fn dimension_associativity_roundtrips() {
stripped.push(t);
}
}
let foreign = read_dxf(craft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
let foreign = read_dxf(cadcraft_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);
@@ -1727,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 = craft_dxf::parse(text.as_bytes()).unwrap();
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
// Each reference to the polyline: (72, 73, 91).
let refs: Vec<(i64, i64, i64)> = tags
.iter()
@@ -1756,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 = craft_dxf::Tag::new(t.code, craft_dxf::Value::Real(0.0));
t = cadcraft_dxf::Tag::new(t.code, cadcraft_dxf::Value::Real(0.0));
}
if !skipping {
stripped.push(t);
}
}
let foreign = read_dxf(craft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
match &foreign.entity(hp).unwrap().kind {
EntityKind::Dimension(x) => assert_eq!(x.assoc, on_pl.assoc),
_ => panic!(),
@@ -2071,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 = craft_dxf::parse(text.as_bytes()).unwrap();
let tags = cadcraft_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"));
@@ -2356,8 +2336,8 @@ fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() {
_ => panic!("not a dimension"),
}
// And through DWG.
let dwg = craft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
let back = read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
let back = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect();
assert_eq!(dims.len(), 1);
assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind);
@@ -3588,7 +3568,7 @@ fn proxies_keep_their_class_in_dwg() {
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
use acadrust::objects::ObjectType;
let dwg = craft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg_as(&proxy_file(), "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let class_of = |id: i32| doc.classes.iter().find(|c| i32::from(c.class_number) == id).map(|c| c.dxf_name.clone());
let mut objects: Vec<Option<String>> = doc
@@ -3619,11 +3599,11 @@ fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
let (_, unwritten) = craft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&proxy_file(), "AC1032").unwrap();
assert_eq!(unwritten, [("ACAD_PROXY_OBJECT".to_string(), 1)].into_iter().collect());
let d = read(&proxy_file(), "p.dxf").unwrap();
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
let (_, unwritten) = craft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
let (_, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(&binary, "AC1032").unwrap();
// CADCraft's own save already left the stray proxy out; the binary DXF's other three go.
assert_eq!(unwritten, [("ACAD_PROXY_ENTITY".to_string(), 1), ("ACAD_PROXY_OBJECT".to_string(), 2)].into_iter().collect());
}
@@ -3640,7 +3620,7 @@ fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
let d = read(&proxy_file(), "p.dxf").unwrap();
let binary = write_with(&d, "p.dxf", &SaveOptions { version: Some(Version::R2018), binary: true }).unwrap();
assert!(binary.starts_with(b"AutoCAD Binary DXF"));
let dwg = craft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg_as(&binary, "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let proxies = doc.objects.values().filter(|o| matches!(o, ObjectType::ProxyObject(_))).count()
+ doc.entities().filter(|e| matches!(e, EntityType::Extended(x) if matches!(x.data, ExtendedEntityData::Proxy(_)))).count();
+1 -1
View File
@@ -285,7 +285,7 @@ fn standard_dimassoc_associates_in_dxf_and_dwg() {
let back = read_dxf(plain.as_bytes()).unwrap();
assert_standard_assoc(&c, &back, "DXF");
let dwg = craft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap();
let back = read(&dwg, "case.dwg").unwrap();
assert_standard_assoc(&c, &back, "DWG");
}
+3 -3
View File
@@ -92,7 +92,7 @@ fn an_ac1018_dwg_gets_the_variable_dictionary_autocad_always_writes() {
#[test]
fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n1\n9\n$HIDETEXT\n70\n0\n0\nENDSEC\n0\nEOF\n";
let dwg = craft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
assert_eq!(value(&vars, "DIMASSOC"), Some("1"), "DIMASSOC in {vars:?}");
@@ -107,7 +107,7 @@ fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() {
#[test]
fn impossible_header_values_fall_back_to_the_defaults() {
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n9\n9\n$HIDETEXT\n70\n7\n0\nENDSEC\n0\nEOF\n";
let dwg = craft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary");
assert_eq!(value(&vars, "DIMASSOC"), Some("2"), "DIMASSOC in {vars:?}");
@@ -139,7 +139,7 @@ fn a_variable_dictionary_the_drawing_has_is_left_alone() {
assert!(source.set_drawing_variable("CMLEADERSTYLE", "Notes"));
let dxf = acadrust::DxfWriter::new(&source).write_to_vec().expect("write a DXF with the dictionary");
let dwg = craft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG");
let dwg = cadcraft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG");
let doc = read_dwg(&dwg);
let vars = variable_dictionary(&doc).expect("the drawing's dictionary is kept");
assert_eq!(vars.len(), 1, "{vars:?}");
+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 = 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 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 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: &[craft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
let handle = |tags: &[cadcraft_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}");
+2 -2
View File
@@ -155,7 +155,7 @@ fn a_rotated_linear_dimension_stores_its_projected_length() {
Vec3::new(-40.0, 105.0, 0.0),
);
let dwg = write_with(&d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
let dxf = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let t = tags(&dxf);
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
let m: f64 = t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap();
@@ -242,7 +242,7 @@ fn justified_text_made_here_writes_its_baseline_start_for_every_justification()
/// Group 42 of a dimension in the DXF AutoCAD reads from `d`'s DWG.
fn stored_measurement(d: &Drawing) -> f64 {
let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap();
let dxf = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let t = tags(&dxf);
let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap();
t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap()
+1 -1
View File
@@ -93,7 +93,7 @@ fn dwg_keeps_the_solids_geometry() {
let d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap();
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
// The DWG read back (as the bridge's DXF) binds the same bytes to both solids.
let back = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let mut got = asm_data(&back);
got.sort();
assert_eq!(got, vec![("2A".to_string(), SAB_HEX.to_string()), ("2B".to_string(), SAB_HEX.to_string())]);
+1 -1
View File
@@ -248,7 +248,7 @@ fn hostile_multibyte_never_panics() {
#[test]
fn pre_2007_dwg_text_decodes_through_the_bridge() {
let dxf = ascii_dxf("AC1015", "ANSI_1251", b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2");
let dwg = craft_dwg::dxf_to_dwg(&dxf).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(&dxf).unwrap();
let d = cadcraft_io::read(&dwg, "x.dwg").unwrap();
assert_eq!(names(&d), ("Слой".into(), "Блок".into(), "Привет".into()));
}
+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(craft_dxf::write_ascii(&tags).as_bytes()).unwrap()
cadcraft_io::read_dxf(cadcraft_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 = craft_dxf::parse_ascii(&text).unwrap();
let tags = cadcraft_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)] {
+1 -1
View File
@@ -71,7 +71,7 @@ fn dwg_keeps_true_colours_and_transparency() {
);
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some("AC1032"));
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1032"));
let header = records(&cadcraft_io::write_dxf(&d), "SECTION");
assert_eq!(header.first().and_then(|h| group(h, "1")), Some("AC1015"), "the DXF stays R2000");
+9 -9
View File
@@ -62,7 +62,7 @@ fn opts(version: Version, binary: bool) -> SaveOptions {
}
fn acadver(bytes: &[u8]) -> String {
let tags = craft_dxf::parse(bytes).unwrap();
let tags = cadcraft_dxf::parse(bytes).unwrap();
tags.windows(2).find(|w| w[0].code == 9 && w[0].str() == "$ACADVER").map(|w| w[1].str()).unwrap_or_default()
}
@@ -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!(craft_dxf::is_binary(&bytes), binary, "{what}");
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}");
let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes());
assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, 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 = craft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
let classes: Vec<Vec<i32>> = craft_dxf::sections(&tags)
let tags = cadcraft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
let classes: Vec<Vec<i32>> = cadcraft_dxf::sections(&tags)
.into_iter()
.filter(|s| s.name == "CLASSES")
.flat_map(|s| craft_dxf::records(&s.tags))
.flat_map(|s| cadcraft_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!(craft_dxf::parse(&saved).unwrap(), craft_dxf::parse(escaped.as_bytes()).unwrap());
assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(escaped.as_bytes()).unwrap());
}
/// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the
@@ -166,7 +166,7 @@ fn dwg_at_every_version_reads_back() {
let d = sample();
for v in Version::ALL {
let dwg = write_with(&d, "x.dwg", &opts(v, false)).unwrap();
assert_eq!(craft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
let back = read(&dwg, "x.dwg").unwrap();
assert_eq!(save_version(&back), v, "{v}");
assert_eq!(back.model.len(), d.model.len(), "{v}");
@@ -187,6 +187,6 @@ fn dwg_at_every_version_reads_back() {
#[test]
fn dwg_default_is_at_least_2004() {
let from_r2000 = read(write_dxf(&sample()).as_bytes(), "x.dxf").unwrap();
assert_eq!(craft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
assert_eq!(craft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
assert_eq!(cadcraft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
assert_eq!(cadcraft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
}
+1 -1
View File
@@ -104,7 +104,7 @@ fn with_scale_action() -> String {
/// A scale action goes through CADCraft's own DWG save (the bridge DXF, then acadrust) whole:
/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused
/// such DWGs while acadrust left out the byte after its connections (craft-dwg's
/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's
/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte).
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[test]
+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 = craft_dxf::parse(saved.as_bytes()).unwrap();
let tags = cadcraft_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 = craft_dxf::write_ascii(&tags);
let dxf = cadcraft_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()) };
-1
View File
@@ -37,7 +37,6 @@ mod layout_plot_window;
mod leader_vertex_elevation;
#[cfg(not(target_arch = "wasm32"))]
mod low_handseed;
mod minsert_marker;
mod mleader_content;
mod mleader_roundtrip;
mod mline_roundtrip;
-78
View File
@@ -1,78 +0,0 @@
//! A block reference repeated in rows or columns (MINSERT) is written with the subclass marker
//! `AcDbMInsertBlock`, as AutoCAD writes it; other readers (BricsCAD) draw only the first copy of
//! an array marked `AcDbBlockReference`. A plain INSERT keeps `AcDbBlockReference` (#3127).
use std::sync::Arc;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Insert, Line, Space};
use cadcraft_geom::Vec3;
fn drawing(refs: &[Insert]) -> Drawing {
let mut d = Drawing::new_metric();
let mut block = Block::new("MP");
block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.5, 5.0, 0.0) })));
d.blocks.insert(block.name.clone(), Arc::new(block));
for r in refs {
d.add(&Space::Model, Common::default(), EntityKind::Insert(r.clone())).unwrap();
}
d
}
/// The subclass markers (group 100) of each INSERT record in the DXF text, in file order.
fn insert_markers(text: &str) -> Vec<Vec<String>> {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let pairs: Vec<(&str, &str)> = lines.chunks(2).filter_map(|c| Some((*c.first()?, *c.get(1)?))).collect();
let mut out = Vec::new();
let mut cur: Option<Vec<String>> = None;
for (code, value) in pairs {
if code == "0" {
out.extend(cur.take());
if value == "INSERT" {
cur = Some(Vec::new());
}
} else if code == "100"
&& let Some(m) = cur.as_mut()
{
m.push(value.to_string());
}
}
out.extend(cur);
out
}
fn inserts(d: &Drawing) -> Vec<Insert> {
d.model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect()
}
#[test]
fn minsert_is_written_with_the_minsert_marker() {
let columns = Insert { block: "MP".into(), cols: 6, col_spacing: 2.5, ..Insert::default() };
let rows = Insert { block: "MP".into(), insert: Vec3::new(0.0, 20.0, 0.0), rows: 3, row_spacing: 5.0, ..Insert::default() };
let text = cadcraft_io::write_dxf(&drawing(&[columns, rows]));
let markers = insert_markers(&text);
assert_eq!(markers.len(), 2, "{markers:?}");
for m in &markers {
assert_eq!(m, &["AcDbEntity", "AcDbMInsertBlock"], "an arrayed reference is an AcDbMInsertBlock");
}
}
#[test]
fn plain_insert_keeps_the_block_reference_marker() {
let plain = Insert { block: "MP".into(), insert: Vec3::new(30.0, 0.0, 0.0), ..Insert::default() };
let text = cadcraft_io::write_dxf(&drawing(&[plain]));
assert_eq!(insert_markers(&text), [["AcDbEntity", "AcDbBlockReference"]]);
}
#[test]
fn minsert_array_reads_back_from_its_own_dxf() {
let refs = [
Insert { block: "MP".into(), cols: 6, col_spacing: 2.5, ..Insert::default() },
Insert { block: "MP".into(), insert: Vec3::new(0.0, 20.0, 0.0), cols: 2, rows: 3, col_spacing: 4.0, row_spacing: 5.0, ..Insert::default() },
Insert { block: "MP".into(), insert: Vec3::new(30.0, 0.0, 0.0), ..Insert::default() },
];
let text = cadcraft_io::write_dxf(&drawing(&refs));
let back = inserts(&cadcraft_io::read_dxf(text.as_bytes()).unwrap());
let got: Vec<_> = back.iter().map(|i| (i.block.as_str(), i.insert, i.cols, i.rows, i.col_spacing, i.row_spacing)).collect();
let want: Vec<_> = refs.iter().map(|i| (i.block.as_str(), i.insert, i.cols, i.rows, i.col_spacing, i.row_spacing)).collect();
assert_eq!(got, want);
}
+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 = craft_dxf::parse(saved.as_bytes()).unwrap();
let tags = cadcraft_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();
+1 -1
View File
@@ -59,7 +59,7 @@ fn model_layout_plots_extents_fitted_and_layouts_their_sheet() {
fn model_layout_plot_type_survives_the_dwg_bridge() {
let dwg = cadcraft_io::write(&drawing(), "x.dwg").unwrap();
// What the DWG holds, as the bridge's own DXF.
let dxf = String::from_utf8_lossy(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).to_string();
let dxf = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).to_string();
let p = plots(&dxf);
let model = p.iter().find(|(n, _, _)| n.eq_ignore_ascii_case("Model")).unwrap();
assert_eq!(model.1, Some(1), "{p:?}");
+3 -3
View File
@@ -82,7 +82,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
let mut d = original.clone();
for pass in 1..=2 {
let bytes = write_with(&d, "carets.dwg", &SaveOptions { version: Some(version), binary: false })?;
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()));
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()));
// Inspect the stored DWG text before its DXF rendition: a compensating reader
// must not hide corruption on save. This uses the same codec, not an external oracle.
let native = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read()?;
@@ -93,7 +93,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
});
assert_eq!(text, Some(contents), "native DWG {version:?}, pass={pass}, {handle:?}");
}
let dxf = craft_dwg::dwg_to_dxf(&bytes)?;
let dxf = cadcraft_dwg::dwg_to_dxf(&bytes)?;
let converted = read(&dxf, "bridge.dxf")?;
assert_contents(&converted, &expected, &format!("DWG bridge DXF {version:?}, pass={pass}"))?;
d = read(&bytes, "carets.dwg")?;
@@ -112,7 +112,7 @@ fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult {
let native = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf.into_bytes()))?.read()?;
assert!(native.entities().any(|e| matches!(e, acadrust::EntityType::MText(t) if t.value == "^ C")));
let dwg = write_with(&d, "carets.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })?;
let returned = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg)?)?;
let returned = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg)?)?;
assert!(returned.contains(" 1\r\n^ C\r\n"), "the return bridge must encode the separator independently of authored whitespace");
Ok(())
}
+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<craft_dxf::Tag> {
use craft_dxf::Tag;
fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<cadcraft_dxf::Tag> {
use cadcraft_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<cr
v
}
fn mtext_from_groups(tags: &[craft_dxf::Tag]) -> (Common, MText) {
fn mtext_from_groups(tags: &[cadcraft_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, craft_dxf::Tag::f(46, 20.0));
tags.insert(at, cadcraft_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 => 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),
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),
_ => 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: craft_dxf::Tag| {
let mut set = |code: i32, v: cadcraft_dxf::Tag| {
let at = tags.iter().rposition(|t| t.code == code).unwrap();
tags[at] = v;
};
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));
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));
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] = craft_dxf::Tag::f(41, 175.0);
tags[own_41] = cadcraft_dxf::Tag::f(41, 175.0);
for code in [40, 42] {
let at = tags.iter().rposition(|t| t.code == code).unwrap();
tags[at] = craft_dxf::Tag::f(code, 175.0);
tags[at] = cadcraft_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 -3
View File
@@ -90,7 +90,7 @@ fn dwg_keeps_ole_objects() {
assert_eq!(ole(&d), 1);
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
let back = String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let got = frames(&back);
assert_eq!(got.len(), 1, "{got:?}");
assert_kept(&got[0]);
@@ -99,14 +99,14 @@ fn dwg_keeps_ole_objects() {
let again = cadcraft_io::read(&dwg, "x.dwg").unwrap();
assert_eq!(ole(&again), 1);
let dwg = cadcraft_io::write(&again, "x.dwg").unwrap();
let got = frames(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(got.len(), 1, "{got:?}");
assert_kept(&got[0]);
// Older DWG versions keep it the same way.
for version in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] {
let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(version), binary: false }).unwrap();
let got = frames(&String::from_utf8(craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(got.len(), 1, "{version:?}: {got:?}");
assert_kept(&got[0]);
}
@@ -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 = craft_dxf::parse(dxf.as_bytes()).unwrap();
let tags = cadcraft_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 craft_dxf::parse(dxf.as_bytes()).unwrap() {
for t in cadcraft_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 = craft_dxf::parse(saved.as_bytes()).unwrap();
let tags = cadcraft_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);
+1 -1
View File
@@ -62,7 +62,7 @@ fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, versi
for pass in 1..=2 {
let context = format!("{case} · DWG {version} · pass={pass}");
let bytes = write_with(&current, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}"));
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records);
@@ -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!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
assert_eq!(cadcraft_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 = craft_dxf::parse(&bytes).unwrap();
let tags = cadcraft_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 = craft_dxf::parse(&bytes).unwrap();
let tags = cadcraft_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 = craft_dxf::parse(&bytes).unwrap();
let tags = cadcraft_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 = craft_dxf::parse(&bytes).unwrap();
let tags = cadcraft_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!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
assert_eq!(cadcraft_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 = craft_dxf::parse(&bytes).unwrap();
let tags = cadcraft_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();
+4 -4
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 = craft_dxf::parse(bytes).unwrap();
let tags = cadcraft_dxf::parse(bytes).unwrap();
let sections = sections(&tags);
let tables = records(&sections.iter().find(|s| s.name == "TABLES").unwrap().tags);
let (_, model) = tables
@@ -79,10 +79,10 @@ pub(super) fn check(path: &str, version: Version, binary: bool) {
Err(error) => panic!("{context}: {error}"),
};
if path.ends_with(".dxf") {
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
dxf_owner(&bytes);
} else {
assert_eq!(craft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
}
let back = read(&bytes, path).unwrap();
preserved(&original, &back, &context);
+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 = craft_dxf::parse(text.as_bytes()).expect("parse written DXF");
let tags = cadcraft_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 = craft_dxf::parse(&bytes).expect("parse binary");
let tags = cadcraft_dxf::parse(&bytes).expect("parse binary");
let class = sections(&tags)
.into_iter()
.filter(|sec| sec.name == "CLASSES")
+6 -6
View File
@@ -107,8 +107,8 @@ fn the_block_draw_order_survives_dxf_and_dwg() {
/// The table itself (not CADCraft's reordered records) goes through the DWG bridge (#2410).
#[test]
fn a_partial_block_table_survives_the_dwg_bridge() {
let dwg = craft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap();
let dxf = craft_dwg::dwg_to_dxf(&dwg).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap();
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let d = cadcraft_io::read_dxf(&dxf).unwrap();
assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]);
}
@@ -120,8 +120,8 @@ fn a_zero_sort_handle_draws_last() {
let dxf = block_dxf_with("331\n25B\n5\n0\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n");
let want = ["wipeout", "circle", "vline", "hline"];
assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF");
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
assert_eq!(block_kinds(&back), want, "DWG");
}
@@ -135,7 +135,7 @@ fn two_unlisted_block_entities_each_sort_by_their_own_handle() {
let dxf = block_dxf_with("331\n25B\n5\n25B\n331\n25D\n5\n25D\n");
let want = ["hline", "circle", "vline", "wipeout"];
assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF");
let dwg = craft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&craft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
assert_eq!(block_kinds(&back), want, "DWG");
}

Some files were not shown because too many files have changed in this diff Show More