Compare commits

..
147 changed files with 3277 additions and 7166 deletions
+1 -8
View File
@@ -18,12 +18,6 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
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
@@ -64,7 +58,7 @@ jobs:
PY
release:
needs: [git-sources, credits]
needs: git-sources
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
@@ -76,7 +70,6 @@ jobs:
app-title: CADCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
Generated
+26 -29
View File
@@ -631,7 +631,6 @@ dependencies = [
"log",
"muda",
"objc2-app-kit 0.3.2",
"pollster 1.0.1",
"rfd",
"ron",
"serde_json",
@@ -695,6 +694,15 @@ dependencies = [
"log",
]
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
dependencies = [
"encoding_rs",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
@@ -702,12 +710,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",
@@ -721,7 +729,6 @@ name = "cadcraft-fonts"
version = "0.17.0"
dependencies = [
"cadcraft-geom",
"craft-dxf",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
@@ -744,10 +751,10 @@ dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
"cadcraft-dxf",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-dxf",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
@@ -821,11 +828,9 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
"js-sys",
"log",
"serde_json",
"wasm-bindgen",
@@ -1142,16 +1147,6 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-dxf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=006e4df225fca57e95ead0fb1f91920900798a99#006e4df225fca57e95ead0fb1f91920900798a99"
dependencies = [
"encoding_rs",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
@@ -1214,7 +1209,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -1237,21 +1232,10 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -6226,6 +6210,7 @@ dependencies = [
"nix 0.31.3",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6240,6 +6225,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+2 -3
View File
@@ -13,8 +13,7 @@ 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" }
cadcraft-dxf = { path = "crates/dxf" }
cadcraft-dwg = { path = "crates/dwg" }
cadcraft-color = { path = "crates/color" }
cadcraft-doc = { path = "crates/doc" }
@@ -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 = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+24 -27
View File
@@ -2,18 +2,16 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: language table, CI gate, open-PR count; previously: progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
Precise drafting still has approximate ellipse and spline intersections and a few stubbed prompt options
(object snap tracking, TRIM/EXTEND/OFFSET options and typed snap overrides have landed); dynamic blocks can't be
authored and xrefs can't be edited in place (the Block Editor, REFEDIT, dynamic block editing and XCLIP have landed);
plots go only to PDF, PNG and SVG files (plot styles and the Plot dialog have landed); and a DWG saved by
CADCraft drops what acadrust doesn't model and has never been checked in
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
CADCraft drops everything it doesn't model and has never been checked in
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
is ≈ 600–930 h away.
@@ -21,7 +19,7 @@ is ≈ 600–930 h away.
| Number | Value | Kind |
|---|---|---|
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491 at the 2026-10-10 re-measure, including UI-only commands; not re-measured since; the generated checklist counts engine commands only and now reads 298 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
| Ready for real work | **≈ 38%** | estimated ([target-app-parity.md](docs/target-app-parity.md)) |
| Mainstream practitioner (2D drafter) | **≈ 28%** (lower than the full number: file exchange, stability and interaction discounts multiply) | estimated ([target-app-parity.md](docs/target-app-parity.md#mainstream-practitioner)) |
| Essentials user | **≈ 46%** | estimated ([target-app-parity.md](docs/target-app-parity.md#essentials-user)) |
@@ -82,40 +80,40 @@ Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build:
## Languages
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus eighteen catalogs
(Ukrainian, Brazilian Portuguese, German, Spanish, Czech, Russian, French, Simplified Chinese, Italian, Korean, Swedish, Arabic, Greek, Hungarian, Turkish, Bosnian, Georgian and Vietnamese). Detail: [localization-parity.md](docs/localization-parity.md).
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus partial
Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Status | UI translated |
|---|---|---:|
| English | full | 100% |
| Simplified Chinese | partial | 1,337 catalog rows |
| Spanish | partial | 1,243 catalog rows |
| Simplified Chinese | none | 0% |
| Spanish | none | 0% |
| Hindi | none | 0% |
| Arabic | partial | 1,229 catalog rows |
| French | partial | 1,004 catalog rows |
| Portuguese (Brazilian) | partial | 1,243 catalog rows |
| Arabic | none | 0% |
| French | none | 0% |
| Portuguese | none | 0% |
| Indonesian | none | 0% |
| Japanese | none | 0% |
| Greek | partial | 1,330 catalog rows; independent proofreading pending |
| German | partial | 1,031 catalog rows |
| Korean | partial | 1,235 catalog rows |
| Vietnamese | partial | 1,460 catalog rows (2026-11-07); native review pending |
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
| German | partial | ≈ 100% of the catalog (989 rows) |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Other languages shipped: Ukrainian (1,357 rows, the reference catalog, #36), Czech (1,215), Russian (1,005), Italian (1,352), Swedish (1,382), Hungarian (1,326) and Turkish (1,330), counted 2026-10-24; engine messages stay English. Every catalog but Ukrainian's has strings added since it landed that still fall back to English: 6 (Italian, Swedish) to 296 (French) per `cargo xtask i18n` ([localization-parity.md](docs/localization-parity.md)).
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
## Upcoming
Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md).
1. Gate and land: CI runs `cargo xtask ci` on every PR since #1987 (fmt on main is fixed); land the 450 open PRs (2026-10-24) (15–25 h).
2. Stop losing data on save (DXF done, #528; native MULTILEADER, #459, and the save version, #421, done; through DWG, acadrust still drops some content) (30–45 h).
1. Gate and land: fix fmt on main, run `cargo xtask ci` on every PR, land the 47 open PRs (15–25 h).
2. Stop losing data on save; native MULTILEADER; save DWG/DXF at a chosen version (30–45 h).
3. Real-file DWG/DXF corpus and a black-box check in AutoCAD (10–15 h + owner).
4. ~~Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
editable dynamic input.~~ Done (#379, #441, #60, #519; `snap/tracking.rs`, `pointmod.rs`, `dyninput.rs`).
5. ~~Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER.~~ Done (#397, #408, #547); the stubs left are in gaps F1.
6. ~~Autosave, recovery, AUDIT, RECOVER.~~ Done on the desktop (#406, #416); the web build has no autosave (gaps F4).
4. Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
editable dynamic input (25–35 h).
5. Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER (15–25 h).
6. Autosave, recovery, AUDIT, RECOVER (10–15 h).
7. Exact ellipse/spline geometry and correct polyline offsets (30–50 h).
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h): xrefs (#525), groups (#417), the Block Editor (#824, #833, #885), REFEDIT (#818), XCLIP and XCLIPFRAME (#2354) and dynamic block references (#838–#883) landed; left: dynamic-block authoring, editing an xref in place.
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h).
## Progress log
@@ -138,7 +136,6 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: language table, CI gate and open-PR count re-checked against main 41d9b5e95 |
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
+1 -4
View File
@@ -20,9 +20,7 @@ cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.cadcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -35,5 +33,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
-61
View File
@@ -1,61 +0,0 @@
//! `window.cadcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
//! own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is CADCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on drops its [`PendingReply`], which cancels a request the app has
//! not started, so the app skips it instead of running it late.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, channel};
use cadcraft_ui_egui::{ControlRequest, PendingReply};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
/// The app id in `window.<app>`.
const APP: &str = "cadcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// Put `window.cadcraft` on the page and return the queue the app drains each frame (hand it to
/// `CadApp::with_control`). An existing `window.cadcraft` keeps the functions it already has.
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
let (queue, requests) = channel::<ControlRequest>();
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
if queue.send(control).is_err() {
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(wait(reply))
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
requests
}
/// The app's reply, polled about once a frame. Dropping this future (the page's timeout) drops
/// `reply`, which cancels the request if the app has not started it.
async fn wait(reply: PendingReply) -> Value {
loop {
if let Some(reply) = reply.try_reply() {
return reply;
}
sleep(POLL_MS).await;
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
+1 -4
View File
@@ -10,8 +10,6 @@
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
pub mod download;
#[cfg(target_arch = "wasm32")]
@@ -162,8 +160,7 @@ mod web {
Box::new(move |cc| {
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
let control = crate::api::install(cc.egui_ctx.clone());
let mut app = CadApp::new(session, services()).with_control(control);
let mut app = CadApp::new(session, services());
app.system_languages =
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
+1 -5
View File
@@ -55,11 +55,7 @@ muda = { version = "0.21", default-features = false }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
# `rwh_06`: the window handle a file dialog shown as a sheet attaches to (src/sheet.rs).
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
# Waits for the answer of a file dialog shown as a sheet (src/sheet.rs). MIT OR Apache-2.0, already
# in Cargo.lock through eframe.
pollster = "1"
winit = { version = "0.30", default-features = false }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
+7 -6
View File
@@ -250,17 +250,18 @@ fn parse_line(bytes: &[u8]) -> Line {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
let Value::Object(mut obj) = msg else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
let Some(Value::String(method)) = obj.remove("method") else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
// Moved out of the parsed object, not cloned: `params` can be most of the line.
Line::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
id: obj.remove("id").unwrap_or(Value::Null),
method,
params: obj.remove("params").unwrap_or(json!({})),
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
}
}
+22 -43
View File
@@ -23,15 +23,13 @@ mod launch_args;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod sheet;
mod spacemouse;
mod system_animations;
mod workspace;
use cadcraft_engine::Session;
use cadcraft_ui_egui::switch::Switch;
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
cad: CadApp,
@@ -161,41 +159,28 @@ pub fn install_io() {
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| file_dialog(&Pick::Open).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_save: Some(Box::new(|name: &str| file_dialog(&Pick::Save(name.into())).save_file().map(|p| p.to_string_lossy().to_string()))),
// macOS shows the dialogs as sheets (`sheet::start_pick`), set once the window exists.
start_pick: None,
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
no_file_system: false,
pick_pgp: Some(Box::new(|dir: Option<&str>| {
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).pick_file().map(|p| p.to_string_lossy().to_string())
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
.add_filter(i18n::t("All files"), &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
}
}
/// The file types `pick` offers: (label, extensions). Shared by the blocking dialogs and the
/// macOS sheets.
fn filters(pick: &Pick) -> Vec<(&'static str, Vec<&'static str>)> {
match pick {
Pick::Open => vec![
(i18n::t("Drawings (DWG, DXF)"), vec!["dwg", "dxf", "dwt"]),
(i18n::t("SVG drawings (SVG, SVGZ)"), vec!["svg", "svgz"]),
(i18n::t("All files"), vec!["*"]),
],
Pick::Save(name) => filters_for_save(name).iter().map(|(label, ext)| (i18n::t(label), vec![*ext])).collect(),
Pick::Lisp(_) => vec![(i18n::t("AutoLISP (LSP)"), vec!["lsp"]), (i18n::t("All files"), vec!["*"])],
Pick::Pgp(_) => vec![(i18n::t("Alias files (PGP)"), vec!["pgp"])],
}
}
/// The blocking dialog for `pick` (Windows, Linux, FreeBSD).
fn file_dialog(pick: &Pick) -> rfd::FileDialog {
let dialog = filters(pick).iter().fold(rfd::FileDialog::new(), |d, (label, exts)| d.add_filter(*label, exts));
match pick {
Pick::Save(name) => dialog.set_file_name(name),
Pick::Lisp(dir) | Pick::Pgp(dir) if !dir.is_empty() => dialog.set_directory(dir),
_ => dialog,
pick_save: Some(Box::new(|name: &str| {
filters_for_save(name)
.iter()
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
pick_lisp: Some(Box::new(|dir: &str| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
}
}
@@ -368,12 +353,6 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = CadApp::new(Session::empty(), services());
// File dialogs are sheets on macOS: a modal loop inside the event handler crashes the
// app when the dialog is resized (#1527).
#[cfg(target_os = "macos")]
if let Some(window) = cc.winit_window().cloned() {
app.services.start_pick = Some(sheet::start_pick(window, cc.egui_ctx.clone()));
}
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
if let Some(ui) = restored {
app.ui = ui;
-51
View File
@@ -1,51 +0,0 @@
//! macOS file dialogs as sheets. Shown in line, or from a helper thread that rfd hands to the main
//! thread's modal loop, a panel runs AppKit's modal event loop; resizing it sends window events
//! back into winit's event handler and winit aborts the app (#1527). A sheet returns at once and
//! the window keeps running; a thread of its own waits for the answer.
use std::sync::Arc;
use cadcraft_ui_egui::{Pick, StartPick};
type Answer = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
/// `Services::start_pick`: shows `pick` as a sheet on `window`; `ui` is woken when it answers.
pub fn start_pick(window: Arc<winit::window::Window>, ui: egui::Context) -> StartPick {
Box::new(move |pick, done| {
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<Answer>();
let ui = ui.clone();
// The thread first, so the answer of a sheet once shown always has somewhere to go. If it
// cannot start, `done` is dropped unanswered and the app says the dialog could not be
// shown.
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
done(pollster::block_on(answer));
ui.request_repaint();
}
});
if let Err(e) = spawned {
log::warn!("file dialog: {e}");
return;
}
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
let dialog =
crate::filters(&pick).iter().fold(rfd::AsyncFileDialog::new().set_parent(&*window), |d, (label, exts)| d.add_filter(*label, exts));
let path = |f: rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: Answer = match pick {
Pick::Save(name) => {
let picked = dialog.set_file_name(name).save_file();
Box::pin(async move { picked.await.map(path) })
}
Pick::Lisp(dir) | Pick::Pgp(dir) => {
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(path) })
}
Pick::Open => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(path) })
}
};
let _ = sheet_tx.send(answer);
})
}
+1 -3
View File
@@ -37,9 +37,7 @@ display_name = "Tasos Sangiotis"
sync_github_name = false
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
sync_github_name = true
[people.WladmirJunior]
real_name = "Wladmir Júnior"
-2
View File
@@ -55,8 +55,6 @@ impl ForeignExtra {
pub struct Foreign {
/// CLASS records (groups after `0 CLASS`) of the kept objects' and entities' types.
pub classes: Vec<Vec<RawTag>>,
/// Classes retain every source position, including duplicate names.
pub complete_class_table: bool,
/// Kept objects, in file order.
pub objects: Vec<ForeignObject>,
/// Entries of the named object dictionary that CADCraft doesn't write itself: (key, object).
+21 -378
View File
@@ -66,8 +66,6 @@ fn mb(n: u64) -> String {
pub mod worker;
#[cfg(not(no_dwg_bridge))]
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod proxy_transport;
#[cfg(not(no_dwg_bridge))]
mod native {
@@ -109,13 +107,6 @@ mod native {
}
let mut notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
notes.extend(
guarded(AssertUnwindSafe(|| application_objects(&mut doc))).map_err(|_| "the DWG application object conversion failed".to_string())?,
);
// After application_objects, so the proxy objects it makes are numbered as the DXF's.
notes.extend(
guarded(AssertUnwindSafe(|| super::proxy_transport::prepare(&mut doc))).map_err(|_| "the DWG proxy conversion failed".to_string())?,
);
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
if orphans > 0 {
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
@@ -399,214 +390,6 @@ mod native {
std::panic::catch_unwind(f).map_err(|_| ())
}
/// The version a proxy record of a DWG of `version` names its format by (Open Design
/// Specification for .dwg, proxy objects): 19 for R13, 21 for R14, 23 for 2000, 25 for 2004,
/// 27 for 2007, 29 for 2010, 31 for 2013 and 33 for 2018; `None` for any other version.
pub(super) fn proxy_dwg_version(version: acadrust::DxfVersion) -> Option<i32> {
Some(match version.version_code() {
1012 => 19,
1014 => 21,
1015 => 23,
1018 => 25,
1021 => 27,
1024 => 29,
1027 => 31,
1032 => 33,
_ => return None,
})
}
/// Objects of another application's class that acadrust keeps only as their DWG record
/// (`ObjectType::Unknown`, which has no DXF form): its DXF writer left them out, and the
/// references to them went with them (a dimension's extension dictionary entry naming its
/// application data, say), with no note. Each is carried as a proxy object of its class instead
/// (DXF Reference, ACAD_PROXY_OBJECT), so the DXF keeps it and a DWG written from that DXF has it
/// again: its common object data (owner, reactors, extension dictionary) and the streams after
/// it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg
/// splits an object record. Objects of the drawing database's own classes are not turned into
/// another application's proxies. What is still without a DXF form is named in the notes, so a
/// save over the original is not mistaken for a complete one.
fn application_objects(doc: &mut acadrust::CadDocument) -> Vec<String> {
use acadrust::objects::{ObjectType, ProxyObject};
let classes: Vec<acadrust::classes::DxfClass> = doc.classes.iter().cloned().collect();
let source = doc.dwg_source_version.unwrap_or(doc.version);
if let Some(dwg_version) = proxy_dwg_version(source) {
let maintenance = i32::from(doc.maintenance_version);
for object in doc.objects.values_mut() {
let ObjectType::Unknown { handle, raw_dwg_data: Some(data), raw_dwg_handle_bits, raw_dwg_version: Some(version), type_name, .. } =
&*object
else {
continue;
};
if !application_class(&classes, type_name) {
continue;
}
let raw = acadrust::entities::RawRecord { data: data.clone(), handle_bits: *raw_dwg_handle_bits, version: *version };
let handle = *handle;
// A record that doesn't split stays as it was, and is named below.
let Ok(Ok(record)) = std::panic::catch_unwind(AssertUnwindSafe(|| object_record(&raw, handle.value()))) else { continue };
let Some(class) = classes.iter().find(|c| c.class_number == record.kind && !c.is_an_entity) else { continue };
*object = ObjectType::ProxyObject(ProxyObject {
handle,
owner: record.owner,
reactors: record.reactors,
xdictionary_handle: record.xdictionary,
proxy_id: 499,
class_id: i32::from(class.class_number),
dwg_version,
maintenance_version: maintenance,
version: (maintenance << 16) | dwg_version,
payload: record.payload,
text_payload: record.text_payload,
object_ids: record.object_ids,
..Default::default()
});
}
}
let mut left: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for object in doc.objects.values() {
if let ObjectType::Unknown { type_name, raw_dxf_codes: None, .. } = object {
let n = left.entry(type_name.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
if left.is_empty() {
return Vec::new();
}
let total = left.values().fold(0usize, |a, n| a.saturating_add(*n));
let mut list: Vec<String> = left.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t:.60} × {n}")).collect();
if left.len() > NOTE_TYPES {
list.push(format!("{} more types", left.len() - NOTE_TYPES));
}
vec![format!(
"DWG: {total} objects of types CADCraft doesn't keep were left out ({}); saving over the original file would lose them",
list.join(", ")
)]
}
/// Whether `name` is an object class of another application, which only that application
/// rebuilds: not one of the drawing database's own classes (`AcDb…`/`ACDB…`/`ACAD_…`), whose
/// leftovers the database regenerates itself.
fn application_class(classes: &[acadrust::classes::DxfClass], name: &str) -> bool {
classes.iter().any(|c| {
!c.is_an_entity
&& c.dxf_name.eq_ignore_ascii_case(name)
&& !c.cpp_class_name.starts_with("AcDb")
&& !["ACDB", "ACAD_", "ACAD"].iter().any(|p| c.dxf_name.to_ascii_uppercase().starts_with(p))
})
}
/// An application object acadrust kept only as its record: the parts a proxy object carries.
struct ObjectRecord {
kind: i16,
owner: acadrust::types::Handle,
reactors: Vec<acadrust::types::Handle>,
xdictionary: Option<acadrust::types::Handle>,
payload: acadrust::objects::ProxyPayload,
text_payload: acadrust::objects::ProxyPayload,
object_ids: Vec<acadrust::objects::ProxyObjectReference>,
}
/// Split an object record into its common object data and the uninterpreted streams after it
/// (data bits, R2007+ strings and object references), as an application object's proxy holds
/// them (Open Design Specification for .dwg, common object data and proxy objects). The record
/// is hostile: sizes are checked against it, and acadrust's checked stream reader does the rest.
fn object_record(raw: &acadrust::entities::RawRecord, handle: u64) -> Result<ObjectRecord, String> {
use acadrust::io::dwg::{
DwgVersion, dwg_reference_type::DwgReferenceType, dwg_stream_readers::object_reader::DwgObjectReader,
dwg_stream_writers::bit_writer::DwgBitWriter,
};
use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind};
use acadrust::types::{DxfVersion, Handle};
if raw.data.len() > 32 << 20 {
return Err("object record exceeds the transport limit".into());
}
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "object has a negative handle stream size")?;
let total_bits = raw.data.len().checked_mul(8).ok_or("object record is too large")?;
if handle_bits > total_bits {
return Err("object handle stream exceeds its record".into());
}
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| e.to_string())?;
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, std::collections::HashMap::new()).map_err(|e| e.to_string())?;
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| e.to_string())?;
if kind < 500 {
return Err("object has no application class".into());
}
let common = reader.read_common_non_entity_data(&mut streams, kind);
streams.check_error().map_err(|e| e.to_string())?;
if common.common.handle != handle {
return Err("object record names another handle".into());
}
let read_bits = |count: usize, bit: &mut dyn FnMut() -> bool| -> Result<ProxyPayload, String> {
if count > total_bits {
return Err("object stream exceeds its record".into());
}
let n = u32::try_from(count).map_err(|_| "object stream is too large")?;
let mut bytes = vec![0u8; count.div_ceil(8)];
for index in 0..count {
if bit()
&& let Some(b) = bytes.get_mut(index / 8)
{
*b |= 0x80 >> (index % 8);
}
}
Ok(ProxyPayload::from_bits(&bytes, n))
};
let main = usize::try_from(streams.main_remaining_bits()).map_err(|_| "object body has an invalid size")?;
let payload = read_bits(main, &mut || streams.read_bit())?;
let text_payload = if raw.version >= DxfVersion::AC1021 {
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "object has a negative string stream size")?;
read_bits(bits, &mut || streams.read_text_bit())?
} else {
ProxyPayload::default()
};
// Every reference to the end of the handle stream, null ones too: an object keeps a null pointer in its
// field's place, and the references after it are the next fields' (as acadrust reads a proxy's own). R2010+
// records give the stream's exact length; the bits of its last byte past it are padding.
let mut object_ids = Vec::new();
let mut left = if raw.version >= DxfVersion::AC1024 {
streams.get_handle_bits().max(0).min(streams.handle_remaining_bits())
} else {
streams.handle_remaining_bits()
};
while left >= 8 {
if object_ids.len() >= 100_000 {
return Err("object has too many references".into());
}
let before = streams.handle_remaining_bits();
let (target, reference) = streams.read_handle_reference(handle);
let used = before.saturating_sub(streams.handle_remaining_bits());
if used <= 0 || used > left {
break;
}
left -= used;
let kind = match reference {
DwgReferenceType::SoftOwnership => ProxyReferenceKind::SoftOwnership,
DwgReferenceType::HardOwnership => ProxyReferenceKind::HardOwnership,
DwgReferenceType::SoftPointer => ProxyReferenceKind::SoftPointer,
DwgReferenceType::HardPointer => ProxyReferenceKind::HardPointer,
DwgReferenceType::Undefined => ProxyReferenceKind::Undefined,
};
object_ids.push(ProxyObjectReference { handle: Handle::from(target), kind });
}
streams.check_error().map_err(|e| e.to_string())?;
Ok(ObjectRecord {
kind,
owner: Handle::from(common.owner_handle),
reactors: common.reactors.into_iter().map(Handle::from).collect(),
xdictionary: common.xdictionary_handle.map(Handle::from),
payload,
text_payload,
object_ids,
})
}
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
/// left unmerged. Returns the cells visited.
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
@@ -963,7 +746,7 @@ mod native {
/// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end
/// of the HEADER section: a large input without one costs time, not memory (a line index of
/// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`.
pub(super) fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
let mut offset = 0usize;
let mut lines = dxf.split(|b| *b == b'\n').map(|line| {
let at = offset.saturating_add(line.len() - line.trim_ascii_start().len());
@@ -1023,32 +806,32 @@ mod native {
/// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public
/// conversion functions comes here.
pub(super) fn dxf_class_names(dxf: &[u8]) -> Option<Vec<String>> {
let pairs = super::proxy_transport::text_pairs(dxf)?;
let mut names = Vec::new();
let (mut section, mut in_classes, mut in_class) = (false, 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 => {
in_class = kind == "CLASS";
// Even an unnamed record keeps its place in the source class table.
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii());
let (mut in_classes, mut in_class) = (false, false);
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
match (code, value) {
(b"0", b"ENDSEC") if in_classes => break,
(b"2", b"CLASSES") => in_classes = true,
(b"0", v) if in_classes => {
in_class = v == b"CLASS";
// Every record keeps its place, named or not: a proxy numbers its class by
// place, so a record without a name must not move the ones after it. An empty
// name matches no class, and such a proxy is left out.
if in_class {
names.push(String::new());
}
}
(1, value) if in_class => {
(b"1", v) if in_class => {
if let Some(last) = names.last_mut() {
*last = value.to_string();
*last = String::from_utf8_lossy(v).into_owned();
}
in_class = false;
}
_ => section = false,
_ => {}
}
}
Some(names)
@@ -1094,17 +877,8 @@ mod native {
return left_out(objects.len(), entities.len());
}
};
// Name matching cannot choose between different source classes with the same name.
// Count those proxies as unwritten rather than silently changing their class identity.
let mut unique = std::collections::HashMap::new();
for name in classes {
unique.entry(name.to_uppercase()).and_modify(|value| *value = false).or_insert(true);
}
let number = |id: i32| -> Option<i32> {
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
if unique.get(&name.to_uppercase()) != Some(&true) {
return None;
}
doc.classes.get_by_name(name).map(|c| i32::from(c.class_number))
};
let objects: Vec<(acadrust::Handle, Option<i32>)> = doc
@@ -1197,8 +971,7 @@ mod native {
Some(d) => d,
None => dxf.to_vec(),
};
let (classes, source) = guarded(AssertUnwindSafe(|| (dxf_class_names(&data), super::proxy_transport::source_classes(&data))))
.map_err(|_| "the DXF proxy class conversion failed".to_string())?;
let classes = dxf_class_names(&data);
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
.map_err(|e| format!("DXF: {e}"))?;
@@ -1213,17 +986,7 @@ mod native {
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
let unwritten = guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
// From 2013 the DWG takes the source classes as they are, duplicates and metadata
// included; otherwise (before 2013, a binary DXF, classes that can't be written back)
// proxies are matched to classes by name or left out.
let restored = match source {
Some(source) if doc.version >= acadrust::DxfVersion::AC1027 => super::proxy_transport::restore_classes(&mut doc, source),
_ => None,
};
let proxies = match restored {
Some((objects, entities)) => left_out(objects, entities),
None => proxy_classes(&mut doc, classes.as_deref()),
};
let proxies = proxy_classes(&mut doc, classes.as_deref());
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
@@ -1396,126 +1159,6 @@ mod native {
assert_eq!(guarded(|| 3), Ok(3));
}
}
#[cfg(test)]
mod application_object_tests {
use super::*;
use acadrust::CadDocument;
use acadrust::classes::DxfClass;
use acadrust::objects::{ObjectType, ProxyObjectReference, ProxyPayload, ProxyReferenceKind, RegisteredClassObject};
use acadrust::types::{DxfVersion, Handle};
/// An object of another application's class that acadrust keeps only as its record (a
/// vertical product's dimension data, say) is carried as that class's proxy object, with its
/// data bits, references, owner and extension dictionary, through DXF and back to DWG, twice.
#[test]
fn undecoded_application_object_is_kept_as_its_proxy() {
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
let mut source = CadDocument::new();
source.version = version;
source.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
let handle = Handle::from(0x2Bu64);
let target = Handle::from(0x2Cu64);
let payload = ProxyPayload::from_bits(&[0x5A, 0x3C, 0xC0], 18);
// A null pointer between two references: it keeps its place, the one after it is not lost.
let references = vec![
ProxyObjectReference { handle: target, kind: ProxyReferenceKind::HardPointer },
ProxyObjectReference { handle: Handle::from(0u64), kind: ProxyReferenceKind::SoftPointer },
ProxyObjectReference { handle: Handle::from(0x0Cu64), kind: ProxyReferenceKind::HardPointer },
];
source.objects.insert(
handle,
ObjectType::RegisteredClass(RegisteredClassObject {
handle,
owner: Handle::from(0x0Cu64),
dxf_name: "TESTCONTAINER".into(),
cpp_class_name: "xyzDbTestContainer".into(),
payload: payload.clone(),
object_ids: references.clone(),
..Default::default()
}),
);
let mut dwg = acadrust::DwgWriter::write_to_vec(&source).unwrap();
// acadrust has no model of it: read back, it is only its record.
let decoded = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
assert!(matches!(decoded.objects.get(&handle), Some(ObjectType::Unknown { .. })), "{:?}", decoded.objects.get(&handle));
for generation in 0..2 {
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
assert!(notes.is_empty(), "{version:?} {generation}: {notes:?}");
// The DXF has the object, by its handle, as a proxy record.
let text = String::from_utf8_lossy(&dxf);
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let record = lines.windows(4).any(|w| w == ["0", "ACAD_PROXY_OBJECT", "5", "2B"]);
assert!(record, "{version:?} {generation}: no proxy record for the object");
dwg = dxf_to_dwg_as(&dxf, "AC1032").unwrap();
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
let Some(ObjectType::ProxyObject(proxy)) = doc.objects.get(&handle) else { panic!("{version:?} {generation}: proxy object") };
assert_eq!(proxy.payload.bit_count, payload.bit_count);
assert_eq!(proxy.payload.data(), payload.data());
assert_eq!(proxy.object_ids, references);
let class = doc.classes.iter().find(|c| i32::from(c.class_number) == proxy.class_id).unwrap();
assert_eq!(class.dxf_name, "TESTCONTAINER");
assert!(!class.is_an_entity);
}
}
}
/// The database's own classes are not turned into another application's proxies; what is
/// left without a DXF form is said, not dropped silently.
#[test]
fn database_objects_without_a_form_are_reported() {
let mut doc = CadDocument::new();
doc.classes.add_or_update(DxfClass::new("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable"));
let handle = Handle::from(0x40u64);
doc.objects.insert(
handle,
ObjectType::Unknown {
type_name: "BLOCKPROPERTIESTABLE".into(),
handle,
owner: Handle::from(0x0Cu64),
raw_dxf_codes: None,
raw_dwg_data: Some(vec![0; 8]),
raw_dwg_handle_bits: 0,
raw_dwg_version: Some(DxfVersion::AC1032),
},
);
let notes = application_objects(&mut doc);
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
assert_eq!(
notes,
[
"DWG: 1 objects of types CADCraft doesn't keep were left out (BLOCKPROPERTIESTABLE × 1); saving over the original file would lose them"
]
);
}
/// A record that doesn't split stays as it was and is named, never guessed at.
#[test]
fn invalid_object_records_are_rejected() {
for handle_bits in [-1, 1000] {
let raw = acadrust::entities::RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
assert!(object_record(&raw, 42).is_err());
}
let mut doc = CadDocument::new();
doc.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
let handle = Handle::from(0x41u64);
doc.objects.insert(
handle,
ObjectType::Unknown {
type_name: "TESTCONTAINER".into(),
handle,
owner: Handle::from(0x0Cu64),
raw_dxf_codes: None,
raw_dwg_data: Some(vec![0xFF; 4]),
raw_dwg_handle_bits: 1000,
raw_dwg_version: Some(DxfVersion::AC1032),
},
);
let notes = application_objects(&mut doc);
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
assert!(notes.iter().any(|n| n.contains("(TESTCONTAINER × 1)")), "{notes:?}");
}
}
}
#[cfg(not(no_dwg_bridge))]
@@ -2003,7 +1646,7 @@ mod tests {
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
/// from DXF is written whole, with the byte AutoCAD writes (0).
#[cfg(not(no_dwg_bridge))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
use acadrust::objects::*;
-632
View File
@@ -1,632 +0,0 @@
//! 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());
}
}
}
+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.
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 }
+24 -2
View File
@@ -178,14 +178,34 @@ fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: b
Ok(json!({ "created": n }))
}
/// The most objects EXPLODE of one associative array makes from its source objects (ten times the
/// default MAXARRAY).
const MAX_EXPLODED_OBJECTS: usize = 1_000_000;
/// EXPLODE of an associative array: every item becomes the objects of its block (the source
/// objects, or a replacement block's reference), in world coordinates. `None` for other objects.
///
/// An array that would explode into more than [`MAX_EXPLODED_OBJECTS`] objects (items times the
/// objects of the source block, which a file sets freely) is not expanded here: `None`, so EXPLODE
/// takes the array block's own items.
pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
let EntityKind::Insert(ins) = &e.kind else { return None };
let p = d.array_params(e)?;
let blk = d.block(&ins.block)?;
let m = ins.transform(blk.base.xy());
let mut out = Vec::new();
// Count first: a hostile array must be refused before any entity is cloned.
let total = blk
.entities
.iter()
.map(|it| match &it.kind {
EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&p.source) => d.block(&i.block).map_or(1, |b| b.entities.len()),
_ => 1,
})
.fold(0usize, usize::saturating_add);
if total > MAX_EXPLODED_OBJECTS {
return None;
}
let mut out = Vec::with_capacity(total);
for item in blk.entities.iter() {
let item = placed(e, item, &m);
let source = match &item.kind {
@@ -193,7 +213,9 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
_ => None,
};
match source {
Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))),
Some((im, b)) => {
out.extend(b.entities.iter().map(|se| placed(&item, se, &im)));
}
None => out.push(item),
}
}
+75 -15
View File
@@ -2,9 +2,9 @@
//! block name and values, or one row per reference with its handle, layer, position and
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use cadcraft_doc::{EntityKind, Insert};
use cadcraft_doc::{Drawing, EntityKind, Insert};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
@@ -88,22 +88,75 @@ fn matches(spec: &str, text: &str) -> bool {
spec.split(',').any(|p| wildcard(p.trim(), text))
}
/// The attribute values of `ins`: its own, then the constant ones of its block.
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
if let Some(b) = d.block(&ins.block) {
for e in b.entities.iter() {
if let EntityKind::AttDef(a) = &e.kind
&& a.constant
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
{
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
}
/// The constant attribute values of a block definition whose tags are selected for output.
fn constants(b: &cadcraft_doc::Block, selected: &[String]) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = Vec::new();
for e in b.entities.iter() {
if let EntityKind::AttDef(a) = &e.kind
&& a.constant
&& selected.iter().any(|tag| tag == &a.tag.to_ascii_uppercase())
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
{
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
}
}
v
}
/// The attribute values of `ins` that `o.tags` selects: its own, then the constant ones of its
/// block (`consts`, read once per block). Values no column shows are not copied.
fn values(ins: &Insert, consts: &[(String, String)], selected: &[String]) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = ins
.attribs
.iter()
.map(|a| (a.tag.to_ascii_uppercase(), a))
.filter(|(t, _)| selected.contains(t))
.map(|(t, a)| (t, a.text.value.clone()))
.collect();
for (t, val) in consts {
if !v.iter().any(|(k, _)| k.eq_ignore_ascii_case(t)) {
v.push((t.clone(), val.clone()));
}
}
v
}
/// Select the output columns before copying any attribute text. A block definition can contain
/// many large constants that do not survive the table's 190-column limit.
fn selected_tags(ents: &[std::sync::Arc<cadcraft_doc::Entity>], d: &Drawing, o: &Options) -> Vec<String> {
let mut selected = Vec::new();
let mut add = |tag: &str| {
let tag = tag.to_ascii_uppercase();
if selected.len() < MAX_TAGS && matches(&o.tags, &tag) && !selected.contains(&tag) {
selected.push(tag);
}
};
let mut scanned = std::collections::HashSet::new();
for entity in ents {
let EntityKind::Insert(ins) = &entity.kind else { continue };
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
if anonymous || !matches(&o.blocks, &ins.block) {
continue;
}
for attribute in &ins.attribs {
add(&attribute.tag);
}
// A block's constants are read once, however many inserts reference it.
if scanned.insert(ins.block.as_str())
&& let Some(block) = d.block(&ins.block)
{
for entity in block.entities.iter() {
if let EntityKind::AttDef(attribute) = &entity.kind
&& attribute.constant
{
add(&attribute.tag);
}
}
}
}
selected
}
/// The block references to extract from: `handles` when given, else model space and the layouts.
fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
let d = s.doc()?;
@@ -112,7 +165,9 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
} else {
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
};
let selected = selected_tags(&ents, d, o);
let mut out = Vec::new();
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
for e in &ents {
let EntityKind::Insert(ins) = &e.kind else { continue };
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
@@ -121,12 +176,17 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
}
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
out.push(Found {
block,
handle: e.handle.hex(),
layer: e.common.layer.clone(),
at: ins.insert.xy(),
rotation: ins.rotation.to_degrees(),
values: values(d, ins),
block: block.clone(),
values: values(
ins,
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
&selected,
),
});
}
Ok(out)
+7 -7
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};
@@ -16,7 +16,7 @@ use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_co
use crate::{Accept, Input, Prompt, Result, Session, Step};
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
pub use pgp::{MAX_ALIASES, alias_name, command_id};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
@@ -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);
+12 -8
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
@@ -375,16 +375,20 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
}
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
let mut boxes = Vec::new();
let mut bound = |d: &Drawing, h: Handle| {
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
// One block-bounds cache for each drawing, so a block many differing entities refer to is
// measured once, not once per entity.
let (mut cur_cache, mut other_cache) = (BlockBoundsCache::default(), BlockBoundsCache::default());
fn bound<'a>(boxes: &mut Vec<Bounds2>, d: &'a Drawing, h: Handle, cache: &mut BlockBoundsCache<'a>) {
if let Some(b) = d.entity(h).map(|e| entity_bounds_cached(d, e, 0, cache)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite())
{
boxes.push(b);
}
};
added.iter().for_each(|h| bound(&cur, *h));
removed.iter().for_each(|h| bound(&other, *h));
}
added.iter().for_each(|h| bound(&mut boxes, &cur, *h, &mut cur_cache));
removed.iter().for_each(|h| bound(&mut boxes, &other, *h, &mut other_cache));
for (a, b, _, _) in &changed {
bound(&cur, *a);
bound(&other, *b);
bound(&mut boxes, &cur, *a, &mut cur_cache);
bound(&mut boxes, &other, *b, &mut other_cache);
}
let regions = regions(&boxes);
let ch: Vec<Value> = changed
+2 -83
View File
@@ -1,16 +1,11 @@
//! The user's command alias file (the publicly documented `.pgp` format: `ALIAS, *COMMAND` lines,
//! `;` comments): ALIASEDIT and REINIT.
//!
//! A loaded file replaces the user aliases, which add to or override the built-in ones for every
//! drawing; command names themselves always win. Lines that define external commands
//! A loaded file adds aliases or overrides the built-in ones for the rest of the session, for
//! every drawing; command names themselves always win. Lines that define external commands
//! (programs the file would have the application start) are never run: they are skipped with a
//! note. CADCraft ships no alias file; the aliases built into the command catalog stay the
//! defaults.
//!
//! The aliases are user settings, never part of a drawing: [`alias_profile`] gives them (and the
//! file REINIT reads) to the host's user profile, and [`restore_aliases`] brings them back on the
//! next start. The desktop and web hosts keep that profile; the headless CLI has no profile, so
//! aliases set in a script last for that session only.
use std::collections::BTreeMap;
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
@@ -25,21 +20,10 @@ use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
pub const MAX_ALIASES: usize = 10_000;
/// Longest alias name.
const MAX_ALIAS_CHARS: usize = 64;
/// Longest command id (the catalog's longest is `texttofront.dimensions`; a test keeps it true).
const MAX_COMMAND_ID_CHARS: usize = 32;
/// Longest line read.
const MAX_LINE_CHARS: usize = 4096;
/// Most warnings reported.
const MAX_WARNINGS: usize = 100;
/// Longest alias file path kept in the user profile.
const MAX_ALIAS_FILE_CHARS: usize = 1024;
/// The largest alias section [`alias_profile`] can produce: [`MAX_ALIASES`] entries, each a
/// [`MAX_ALIAS_CHARS`]-character name (up to four UTF-8 bytes per character, and JSON adds its
/// quotes) mapped to a command id, plus the alias file path and the section's keys. A host that
/// keeps the section in its user profile allows at least this much, so a full set always comes
/// back after a restart.
pub const MAX_ALIAS_PROFILE_BYTES: usize = MAX_ALIASES * (MAX_ALIAS_CHARS * 4 + MAX_COMMAND_ID_CHARS + 6) + MAX_ALIAS_FILE_CHARS * 4 + 64;
#[derive(Default)]
struct Aliases {
@@ -59,49 +43,6 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
store().map.get(name).cloned()
}
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
pub fn alias_profile() -> Value {
let st = store();
// A path [`restore_aliases`] would refuse is left out, so it can't grow the profile either.
json!({ "aliases": st.map, "file": st.file.as_deref().filter(|f| keepable_path(f)) })
}
/// Whether the profile keeps an alias file path: short enough, and no control characters.
fn keepable_path(f: &str) -> bool {
f.chars().count() <= MAX_ALIAS_FILE_CHARS && !f.chars().any(char::is_control)
}
/// Restore aliases saved by [`alias_profile`], replacing the user aliases and the file.
///
/// The saved set is untrusted (a hand-edited or older profile): entries that are not aliases of
/// real commands are skipped, command names are never aliased, at most [`MAX_ALIASES`] are kept,
/// and a malformed section changes nothing.
pub fn restore_aliases(saved: &Value) {
let Some(obj) = saved.as_object() else { return };
let file = match obj.get("file") {
None | Some(Value::Null) => None,
Some(Value::String(f)) if keepable_path(f) => Some(f.clone()),
Some(Value::String(_)) => None,
// A file entry that is not a path makes the section malformed: keep the aliases as they are.
Some(_) => return,
};
let Some(map) = obj.get("aliases").and_then(Value::as_object) else { return };
let mut aliases = BTreeMap::new();
// Entries that are skipped don't use up the cap.
for (alias, target) in map {
if aliases.len() >= MAX_ALIASES {
break;
}
let Ok(name) = alias_name(alias) else { continue };
let Some(id) = target.as_str().and_then(command_id) else { continue };
aliases.insert(name, id);
}
let mut st = store();
st.map = aliases;
st.file = file;
}
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
pub fn command_id(target: &str) -> Option<String> {
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
@@ -260,25 +201,3 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
CommandSpec::new("reinit", "Reinitialize", run_reinit).params("{file? | text?} (default: the alias file loaded last)").enabled(always).noundo(),
]
}
#[cfg(test)]
mod tests {
use super::MAX_COMMAND_ID_CHARS;
/// [`super::MAX_ALIAS_PROFILE_BYTES`] bounds a saved alias's command id by the longest id in
/// the catalog: keep that true as commands are added.
#[test]
fn every_command_id_fits_the_saved_alias_bound() {
for spec in crate::cmd::command_specs() {
assert!(spec.id.chars().count() <= MAX_COMMAND_ID_CHARS, "`{}` exceeds the alias profile bound", spec.id);
}
}
/// An alias file path the profile couldn't restore isn't saved in it.
#[test]
fn only_paths_the_profile_restores_are_saved() {
assert!(super::keepable_path("C:\\support\\mine.pgp"));
assert!(!super::keepable_path(&"x".repeat(super::MAX_ALIAS_FILE_CHARS + 1)));
assert!(!super::keepable_path("mine\n.pgp"));
}
}
+12
View File
@@ -117,6 +117,12 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
}
// Each Copy clones every selected object: the copies together stay within MAXARRAY, as an array's do.
// Erased handles are skipped by `apply`, so only live objects count (none: nothing is copied).
let live = counts(s, &objs)?.0;
if live > 0 {
crate::cmd::array::check_size(s, ops.iter().filter(|o| matches!(o, Op::Copy(_))).count() as u64, live)?;
}
let mut copies = Vec::new();
for op in ops {
copies.extend(apply(s, &objs, &mut base, op)?);
@@ -150,6 +156,12 @@ pub(super) struct MocoroM {
impl MocoroM {
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
if matches!(op, Op::Copy(_)) {
let live = counts(s, &self.objs)?.0;
if live > 0 {
crate::cmd::array::check_size(s, 1, live)?;
}
}
let before = s.state()?.doc.clone();
let base = self.base;
apply(s, &self.objs, &mut self.base, op)?;
+2 -34
View File
@@ -11,8 +11,6 @@ fn text_of(k: &EntityKind) -> Option<String> {
EntityKind::Text(t) => Some(t.value.clone()),
EntityKind::AttDef(a) => Some(a.text.value.clone()),
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
// A multileader's text (one with a block instead has none, #1081).
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
_ => None,
}
@@ -27,16 +25,11 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
EntityKind::Text(t) => t.value = text.to_string(),
EntityKind::AttDef(a) => a.text.value = text.to_string(),
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
EntityKind::MLeader(m) if m.text.is_some() => {
if let Some(t) = &mut m.text {
t.contents = text.replace('\n', "\\P");
}
}
EntityKind::Dimension(d) => {
d.text = if text == "<>" { String::new() } else { text.to_string() };
d.block = None;
}
_ => return Err(other("Select a text, mtext, multileader, attribute definition or dimension.")),
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
}
set_kind(s, h, k)
}
@@ -60,18 +53,12 @@ pub(super) struct TextEditM {
single: bool,
/// Asking for the edit mode.
mode: bool,
/// The MTEXT being edited, for the in-place editor (#842).
mtext: Option<cadcraft_doc::MText>,
}
impl Interactive for TextEditM {
fn name(&self) -> &'static str {
"TEXTEDIT"
}
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
self.h.as_ref().and(self.mtext.clone())
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.mode {
let current = if self.single { "Single" } else { "Multiple" };
@@ -96,24 +83,7 @@ impl Interactive for TextEditM {
match (&self.h, i) {
(None, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
// Refused when picked, not after the new text has been typed.
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let kind = curves::entity(s, h)?.kind;
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?;
self.mtext = match kind {
EntityKind::MText(m) => Some(m),
// A multileader's text opens in the same editor (#1081).
EntityKind::MLeader(m) => m.text,
_ => None,
};
// The editor would drop part of a text this long, and saving would lose it: the
// command line asks for it instead.
if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) {
self.mtext = None;
s.echo("This text is too long for the editor; enter the new text at the command line.");
}
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
self.h = Some((h, old));
Ok(Step::Continue)
}
@@ -123,12 +93,10 @@ impl Interactive for TextEditM {
set_text(s, h, &t)?;
self.edits.push((h, before));
self.h = None;
self.mtext = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(Some(_), Input::Enter) => {
self.h = None;
self.mtext = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(None, Input::Enter) => Ok(Step::Done),
-11
View File
@@ -402,17 +402,6 @@ fn apply_kind_properties(
t.rotation = x.to_radians();
}
}
// A multileader's text: its contents (#1081) and height.
EntityKind::MLeader(m) => {
if let Some(t) = &mut m.text {
if let Some(v) = str_param(params, "text") {
t.contents = v.replace('\n', "\\P");
}
if let Some(x) = num("height").filter(|x| *x > 0.0) {
t.height = x;
}
}
}
EntityKind::LwPolyline(pl) => {
if let Some(x) = num("width").filter(|x| *x >= 0.0) {
pl.const_width = x;
+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;
@@ -248,20 +248,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)),
}
}
+11 -112
View File
@@ -33,9 +33,6 @@ pub struct CharFmt {
pub font: Option<String>,
/// Absolute character height set inline (`\H`); `None`: the text's.
pub height: Option<f64>,
/// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written
/// back as it was so the part keeps scaling with the text's height.
pub height_rel: Option<f64>,
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
pub color: Option<MTextColor>,
}
@@ -102,60 +99,26 @@ fn arg_end(chars: &[char], from: usize) -> usize {
impl Formatted {
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
pub fn from_contents(contents: &str) -> Formatted {
Self::from_contents_with_height(contents, None)
}
/// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no
/// inline height before it scales that one instead of being dropped.
pub fn from_contents_with_height(contents: &str, height: Option<f64>) -> Formatted {
Self::parse(contents, height).0
}
/// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters, [`MAX_PARAS`]
/// paragraphs or [`MAX_ATOMS`] kept codes the rest would be dropped, and saving would lose it.
pub fn fits(contents: &str) -> bool {
Self::parse(contents, None).1
}
/// Whether `edited`, what the editor gives back for `original` (a text `height` high), is the
/// text as it was: the same, or what opening it and pressing OK without an edit gives (a
/// non-canonical `{\fArial|b1;first}` comes back written another way).
pub fn leaves_unchanged(original: &str, height: Option<f64>, edited: &str) -> bool {
edited == original || edited == Self::from_contents_with_height(original, height).to_contents()
}
/// The parse, and whether it took in all of `contents`.
fn parse(contents: &str, height: Option<f64>) -> (Formatted, bool) {
let mut f = Formatted::default();
let chars: Vec<char> = contents.chars().collect();
let mut stack: Vec<CharFmt> = Vec::new();
let mut cur = CharFmt::default();
let mut props = ParaProps::default();
f.paras = vec![props.clone()];
// Cleared when a paragraph, a kept code or a nesting level doesn't fit.
let mut whole = true;
// Groups opened past the 64 levels kept: their closing braces pop nothing.
let mut unkept = 0usize;
let mut i = 0;
while let Some(&c) = chars.get(i) {
// Braces after the last character the editor holds add no text.
if f.fmt.len() >= MAX_CHARS && !matches!(c, '{' | '}' | '\r') {
return (f, false);
if f.fmt.len() >= MAX_CHARS {
break;
}
match c {
'{' => {
if stack.len() < 64 {
stack.push(cur.clone());
} else {
unkept = unkept.saturating_add(1);
whole = false;
}
i += 1;
}
'}' => {
if unkept > 0 {
unkept -= 1;
} else if let Some(p) = stack.pop() {
if let Some(p) = stack.pop() {
cur = p;
}
i += 1;
@@ -165,8 +128,6 @@ impl Formatted {
if f.paras.len() < MAX_PARAS {
f.push_char('\n', &cur);
f.paras.push(props.clone());
} else {
whole = false;
}
i += 1;
}
@@ -179,7 +140,6 @@ impl Formatted {
f.push_char('\n', &cur);
f.paras.push(props.clone());
}
Some('P') => whole = false,
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
Some('L') => cur.underline = true,
Some('l') => cur.underline = false,
@@ -191,7 +151,7 @@ impl Formatted {
i = end + 1;
match k {
'f' | 'F' => apply_font(&mut cur, &a),
'H' => apply_height(&mut cur, &a, height),
'H' => cur.height = parse_height(&a, cur.height),
'C' => {
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
}
@@ -217,7 +177,6 @@ impl Formatted {
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
i = end + 1;
}
whole &= f.atoms.len() < MAX_ATOMS;
f.push_atom(code, &cur);
}
}
@@ -228,7 +187,7 @@ impl Formatted {
}
}
}
(f, whole)
f
}
fn push_char(&mut self, c: char, fmt: &CharFmt) {
@@ -486,9 +445,7 @@ impl Formatted {
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
}
if let Some(m) = f.height_rel {
s.push_str(&format!("\\H{}x;", trim(m)));
} else if let Some(h) = f.height {
if let Some(h) = f.height {
s.push_str(&format!("\\H{};", trim(h)));
}
match f.color {
@@ -505,32 +462,12 @@ fn trim(v: f64) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no
/// inline height before it, or a relative one) stays relative; over an inline absolute height it
/// becomes absolute.
fn apply_height(cur: &mut CharFmt, a: &str, base: Option<f64>) {
let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h);
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
let a = a.trim();
let Some(r) = a.strip_suffix(['x', 'X']) else {
if let Some(h) = a.parse::<f64>().ok().and_then(valid) {
cur.height = Some(h);
cur.height_rel = None;
}
return;
};
let Some(m) = r.trim().parse::<f64>().ok().and_then(valid) else { return };
match (cur.height, cur.height_rel) {
(_, Some(r0)) => {
if let Some(m) = valid(r0 * m) {
cur.height_rel = Some(m);
cur.height = base.and_then(|b| valid(b * m));
}
}
(None, None) => {
cur.height_rel = Some(m);
cur.height = base.and_then(|b| valid(b * m));
}
(Some(h), None) => cur.height = valid(h * m).or(cur.height),
match a.strip_suffix(['x', 'X']) {
// A relative height needs the current one; without it the code is dropped.
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
}
}
@@ -556,22 +493,6 @@ fn apply_font(cur: &mut CharFmt, a: &str) {
mod tests {
use super::*;
#[test]
fn a_relative_height_is_written_back_relative() {
let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0));
assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height");
let out = f.to_contents();
assert!(out.contains("\\H2x;big"), "{out}");
assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}");
// Opened again for a taller text, the part scales with it.
assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0));
// Relative over relative multiplies; over an absolute one it is absolute.
let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents();
assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}");
// With no height known it is still kept.
assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x"));
}
#[test]
fn round_trips_formats_and_atoms() {
for src in [
@@ -642,26 +563,4 @@ mod tests {
let _ = f.fmt_at(usize::MAX);
let _ = Formatted::from_contents(&"{".repeat(100_000));
}
/// A text with more paragraphs or kept codes than the editor holds doesn't fit, so TEXTEDIT
/// leaves it to the command line instead of dropping the rest on save.
#[test]
fn too_many_paragraphs_or_codes_do_not_fit() {
assert!(Formatted::fits("one\\Ptwo\\A1;three"));
assert!(!Formatted::fits(&"a\\P".repeat(MAX_PARAS)));
assert!(!Formatted::fits(&"a\n".repeat(MAX_PARAS)));
assert!(!Formatted::fits(&"\\A1;".repeat(MAX_ATOMS + 1)));
assert!(Formatted::fits(&"\\A1;".repeat(MAX_ATOMS)));
// A full formatted run's closing brace adds no text: it still fits.
assert!(Formatted::fits(&format!("{{\\L{}}}", "x".repeat(MAX_CHARS))));
assert!(!Formatted::fits(&format!("{{\\L{}}}y", "x".repeat(MAX_CHARS))));
// OK without an edit leaves a text alone, also one the editor writes another way.
let odd = "{\\fArial|b1;first}";
assert!(Formatted::leaves_unchanged(odd, Some(2.5), &Formatted::from_contents_with_height(odd, Some(2.5)).to_contents()));
assert!(Formatted::leaves_unchanged(odd, Some(2.5), odd));
assert!(!Formatted::leaves_unchanged(odd, Some(2.5), "second"));
// Nesting past the 64 levels the editor keeps doesn't fit.
assert!(Formatted::fits(&format!("{}a{}", "{".repeat(64), "}".repeat(64))));
assert!(!Formatted::fits(&format!("{}a{}", "{".repeat(65), "}".repeat(65))));
}
}
+2 -3
View File
@@ -286,9 +286,8 @@ pub trait Interactive: Send {
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
None
}
/// The multiline text being typed (MTEXT, once its box is known, with empty contents) or edited
/// (TEXTEDIT on an MTEXT or a multileader's text, with its contents): where the UI places its
/// in-place editor and with which height, width, justification and starting text.
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
/// the UI places its in-place editor and with which height, width and justification.
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
None
}
-51
View File
@@ -75,57 +75,6 @@ fn reinit_reads_the_alias_file_again() {
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
}
#[test]
fn a_saved_alias_profile_restores_without_overriding_commands() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
s.execute("aliasedit", &json!({ "set": { "zzp": "qsave", "zzcmd": "line" } })).unwrap();
let saved = cadcraft_engine::cmd::file::alias_profile();
assert_eq!(saved["aliases"]["zzp"], json!("qsave"));
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
assert_eq!(resolve_alias("zzp"), "zzp");
cadcraft_engine::cmd::file::restore_aliases(&saved);
assert_eq!(resolve_alias("zzp"), "qsave");
assert_eq!(resolve_alias("zzcmd"), "line");
assert_eq!(resolve_alias("line"), "line", "command names win");
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
// Hostile saved entries are skipped, and an oversized set is capped without panicking.
let mut hostile = serde_json::Map::new();
for i in 0..12_000 {
hostile.insert(format!("ZZ{i}"), json!("line"));
}
hostile.insert("LINE".into(), json!("circle"));
hostile.insert("ZZLONG".into(), json!("x".repeat(5000)));
hostile.insert("ZZNUM".into(), json!(3));
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": hostile, "file": "mine.pgp" }));
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
assert_eq!(resolve_alias("zzlong"), "zzlong");
assert_eq!(resolve_alias("zzn"), "zzn");
let listing = s.execute("aliasedit", &json!({})).unwrap();
let restored = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
assert!(restored <= cadcraft_engine::cmd::file::MAX_ALIASES, "the restored set is capped, not {restored}");
assert!(restored > 9_000, "the valid entries restore, not just {restored}");
assert_eq!(listing["file"], json!("mine.pgp"));
// Skipped entries don't use up the cap: invalid ones sorted first leave a full valid set.
let mut first_bad = serde_json::Map::new();
for i in 0..cadcraft_engine::cmd::file::MAX_ALIASES {
first_bad.insert(format!("A{i:05}"), json!("nosuchcommand"));
first_bad.insert(format!("B{i:05}"), json!("line"));
}
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": first_bad, "file": null }));
let listing = s.execute("aliasedit", &json!({})).unwrap();
assert_eq!(listing["aliases"].as_object().map_or(0, serde_json::Map::len), cadcraft_engine::cmd::file::MAX_ALIASES);
// A malformed section is skipped without changing what ALIASEDIT holds.
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": { "zzchange": "circle" }, "file": 7 }));
assert_eq!(resolve_alias("zzchange"), "zzchange", "a malformed section restores nothing");
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
assert_eq!(resolve_alias("zz0"), "zz0", "clear still empties the restored set");
}
#[test]
fn hostile_alias_files_never_panic() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
+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
@@ -86,7 +86,6 @@ mod minsert;
mod mirror_degenerate_line;
mod mleader_content;
mod mleader_remember;
mod mleader_text_edit;
mod mline;
mod mocoro;
mod modify_curves;
-124
View File
@@ -1,124 +0,0 @@
//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and
//! hands it to the in-place editor, and the Properties palette's Contents field changes it.
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session};
use serde_json::json;
fn text_of(s: &Session) -> String {
s.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None })
.unwrap()
}
#[test]
fn textedit_and_properties_change_a_multileaders_text() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let (h, at) = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
.unwrap();
// TEXTEDIT on the text: the current contents are the default, and the in-place editor gets
// the multileader's MTEXT.
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
let p = s.current_prompt().unwrap();
assert_eq!(p.message, "Enter new text");
assert_eq!(p.default.as_deref(), Some("example text"));
assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string()));
s.input(Input::Text("edited\nsecond line".into())).unwrap();
assert_eq!(text_of(&s), "edited\\Psecond line");
s.cancel();
// The Properties palette's Contents field.
s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
assert_eq!(text_of(&s), "from properties");
}
/// An edited multileader text is undone in one step and is what a DXF save keeps.
#[test]
fn an_edited_multileader_text_undoes_and_survives_a_dxf_save() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let at = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
.unwrap();
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
s.input(Input::Text("edited\nsecond line".into())).unwrap();
s.cancel();
// 2007 DXF: older versions keep a multileader as a LEADER and an MTEXT.
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
let reopened = cadcraft_io::read_dxf(&cadcraft_io::write_with(s.doc().unwrap(), "x.dxf", &opts).unwrap()).unwrap();
let saved =
reopened.model.iter().find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None });
assert_eq!(saved.as_deref(), Some("edited\\Psecond line"));
s.undo().unwrap();
assert_eq!(text_of(&s), "example text");
}
/// A multileader on a locked layer is refused when it is picked, before the editor opens, and
/// Properties leaves it alone.
#[test]
fn a_multileader_on_a_locked_layer_is_refused_when_picked() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let (h, at) = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
.unwrap();
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("The object is on a locked layer."));
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
assert_eq!(s.current_prompt().unwrap().message, "Select an annotation object");
s.cancel();
let r = s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
assert_eq!(text_of(&s), "example text");
}
#[test]
fn a_text_too_long_for_the_editor_is_asked_for_at_the_command_line() {
let mut s = Session::new();
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
let (h, at) = s
.doc()
.unwrap()
.model
.iter()
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
.unwrap();
let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS));
s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap();
assert_eq!(text_of(&s), long);
s.cmdline("TEXTEDIT").unwrap();
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("This text is too long for the editor; enter the new text at the command line."));
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
assert_eq!(s.current_prompt().unwrap().message, "Enter new text");
// Enter keeps it whole; a typed answer still replaces it, as for any other text.
s.input(Input::Enter).unwrap();
assert_eq!(text_of(&s), long, "the text is not shortened");
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
s.input(Input::Text("short".into())).unwrap();
assert_eq!(text_of(&s), "short");
s.cancel();
}
+14
View File
@@ -233,3 +233,17 @@ fn options_on_objects_that_are_gone_record_nothing() {
assert!(lines(&s).is_empty());
assert_eq!(undo_len(&s), undo, "no empty undo step");
}
#[test]
fn json_form_copies_stay_within_maxarray() {
let mut s = Session::new();
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
// 5,000 Copy options of 30 objects would be 150,000 copies, over the default MAXARRAY of 100,000.
let others: Vec<String> = (0..29).map(|i| line(&mut s, [0.0, f64::from(i + 1)], [10.0, f64::from(i + 1)])).collect();
let mut hs = vec![l];
hs.extend(others);
let ops: Vec<Value> = (0..5_000).map(|i| json!({"copy": [0, i]})).collect();
let e = s.execute("mocoro", &json!({"handles": hs, "base": [0, 0], "ops": ops})).unwrap_err();
assert!(e.to_string().contains("MAXARRAY"), "{e}");
assert_eq!(lines(&s).len(), 30, "nothing was copied");
}
-1
View File
@@ -10,7 +10,6 @@ workspace = true
[dependencies]
cadcraft-geom = { workspace = true }
craft-dxf = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
+8 -68
View File
@@ -33,21 +33,13 @@ pub(crate) fn is_mark(c: char) -> bool {
}
/// Base direction of a paragraph from its first strong character (rules P2/P3): `Some(true)`
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character. As rule
/// P2 requires, characters between an isolate initiator and its matching PDI (or the end of the
/// paragraph, when unmatched) are skipped.
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character.
pub fn base_rtl(s: &str) -> Option<bool> {
let mut isolate_depth: usize = 0;
for c in s.chars() {
match bidi_class(c) {
BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => isolate_depth = isolate_depth.saturating_add(1),
BidiClass::PDI => isolate_depth = isolate_depth.saturating_sub(1),
BidiClass::L if isolate_depth == 0 => return Some(false),
BidiClass::R | BidiClass::AL if isolate_depth == 0 => return Some(true),
_ => {}
}
}
None
s.chars().find_map(|c| match bidi_class(c) {
BidiClass::L => Some(false),
BidiClass::R | BidiClass::AL => Some(true),
_ => None,
})
}
fn para_level(base: Option<bool>) -> Option<Level> {
@@ -357,24 +349,14 @@ pub fn presentation_forms(chars: &[char], keep: impl Fn(char) -> bool) -> Vec<(c
let mut i = 0;
while let Some(&c) = chars.get(i) {
let form = forms.get(i).copied().flatten();
// Combining marks (harakat) between lam and alef are transparent for joining, so they
// don't block the ligature; they stay after it, in logical order.
let alef_at =
if c == '\u{0644}' { chars.get(i + 1..).and_then(|rest| rest.iter().position(|ch| !is_mark(*ch))).map(|p| i + 1 + p) } else { None };
if c == '\u{0644}'
&& let Some(f) = form
&& let Some(j) = alef_at
&& let Some(&a) = chars.get(j)
&& let Some(&a) = chars.get(i + 1)
&& let Some(lig) = lam_alef(a, matches!(f, Form::Medial | Form::Final))
&& keep(lig)
{
out.push((lig, i));
for k in i + 1..j {
if let Some(&m) = chars.get(k) {
out.push((m, k));
}
}
i = j + 1;
i += 2;
continue;
}
let shaped = form.and_then(|f| presentation_form(c, f)).filter(|p| keep(*p)).unwrap_or(c);
@@ -460,48 +442,6 @@ mod tests {
assert!(visual_runs(&[], None).is_empty() && char_levels("", None).is_empty());
}
#[test]
fn paragraph_direction_skips_isolate_contents() {
// RLI שלום PDI abc: the Hebrew sits inside the isolate, so the first strong character is "a".
assert_eq!(base_rtl("\u{2067}שלום\u{2069} abc"), Some(false));
assert_eq!(base_rtl("\u{2066}abc\u{2069} שלום"), Some(true));
assert_eq!(base_rtl("\u{2068}שלום\u{2069}abc"), Some(false));
// Nested isolates: only the outermost PDI closes the outer one.
assert_eq!(base_rtl("\u{2066}a\u{2067}ב\u{2069}a\u{2069}ב"), Some(true));
assert_eq!(base_rtl("\u{2067}\u{2066}a\u{2069}ב\u{2069}a"), Some(false));
// An unmatched initiator isolates the rest of the paragraph.
assert_eq!(base_rtl("\u{2067}שלום"), None);
assert_eq!(base_rtl("\u{2067}שלום\u{2069}"), None);
// A stray PDI does not close anything and does not underflow.
assert_eq!(base_rtl("\u{2069}\u{2069}שלום"), Some(true));
// No strong character outside the isolates.
assert_eq!(base_rtl("12 \u{2066}abc\u{2069} 34"), None);
// Without isolates nothing changes.
assert_eq!(base_rtl(" 123 سلام abc"), Some(true));
}
#[test]
fn harakat_between_lam_and_alef_keep_the_ligature() {
let run = |s: &str| -> Vec<(char, usize)> {
let chars: Vec<char> = s.chars().collect();
presentation_forms(&chars, |_| true)
};
// Lam + fatha + alef: the ligature, then the fatha (logical order kept).
assert_eq!(run("ل\u{064E}ا"), vec![('\u{FEFB}', 0), ('\u{064E}', 1)]);
// Word-medial: س + lam + fatha + shadda + alef gives the final ligature, marks after it.
assert_eq!(run("سل\u{064E}\u{0651}ا"), vec![('\u{FEB3}', 0), ('\u{FEFC}', 1), ('\u{064E}', 2), ('\u{0651}', 3)]);
// Plain lam + alef is unchanged.
assert_eq!(run("لا"), vec![('\u{FEFB}', 0)]);
// Mark after the alef is not between them: unchanged behaviour.
assert_eq!(run("لا\u{064E}"), vec![('\u{FEFB}', 0), ('\u{064E}', 2)]);
// Lam + mark + a non-alef letter: no ligature, the lam joins forward as before.
assert_eq!(run("ل\u{064E}م"), vec![('\u{FEDF}', 0), ('\u{064E}', 1), ('\u{FEE2}', 2)]);
// Lam + mark at the end: no alef, nothing breaks.
assert_eq!(run("ل\u{064E}"), vec![('\u{FEDD}', 0), ('\u{064E}', 1)]);
// A non-mark between lam and alef blocks the ligature.
assert_eq!(run("ل-ا").len(), 3);
}
#[test]
fn hostile_input_never_panics() {
for s in [
+10 -215
View File
@@ -9,8 +9,6 @@ pub(crate) mod para;
/// Strip MTEXT formatting codes to plain text with `\n` paragraph breaks.
/// Stacked fractions `\S1/2;` become `1/2`; `\~` is a space; braces are dropped.
pub fn plain_mtext(s: &str) -> String {
let s = unescape_codes(s);
let s = s.as_ref();
let mut out = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
@@ -65,7 +63,7 @@ pub fn plain_mtext(s: &str) -> String {
i += 7;
}
// Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p).
_ if takes_arg(code) => {
_ => {
let mut j = i + 2;
while let Some(&ch) = chars.get(j) {
if ch == ';' {
@@ -75,8 +73,6 @@ pub fn plain_mtext(s: &str) -> String {
}
i = j + 1;
}
// An unknown code has no argument: drop the backslash and the code only.
_ => i += 2,
}
}
'{' | '}' => i += 1,
@@ -184,96 +180,11 @@ enum Atom {
Para(usize),
}
/// Upper bound on parsed atoms (words, spaces and paragraph breaks) per MTEXT. Hostile contents
/// are cut here: once more than this many atoms are parsed, `parse` stops reading and the rest
/// of the contents is silently dropped from the layout. There is no warning to the caller.
/// Upper bound on parsed atoms (hostile contents).
const MAX_ATOMS: usize = 200_000;
/// Scale of stacked text relative to the surrounding text.
const STACK_SCALE: f64 = 0.7;
/// Codes that take an argument terminated by `;` (besides `\S`, which has its own scan). Any
/// other code stands alone, so a missing `;` must not swallow the text after it.
pub(crate) fn takes_arg(code: char) -> bool {
matches!(code, 'f' | 'F' | 'H' | 'W' | 'Q' | 'T' | 'A' | 'C' | 'c' | 'p')
}
/// The line-spacing factor to lay out with: AutoCAD's 0.25..=4 range, 1 for a missing or
/// non-finite value.
pub(crate) fn sane_line_spacing(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v.clamp(0.25, 4.0) } else { 1.0 }
}
/// Four hex digits at `chars[at..at + 4]`.
fn hex4(chars: &[char], at: usize) -> Option<u32> {
let digits = chars.get(at..at.checked_add(4)?)?;
digits.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
}
/// Push a decoded character so that the tokenizers read it as text, never as markup.
fn push_literal(out: &mut String, c: char) {
if matches!(c, '\\' | '{' | '}') {
out.push('\\');
}
out.push(c);
}
/// Decode `\U+hhhh` (one UTF-16 unit; a surrogate pair gives one character, a lone surrogate
/// U+FFFD) and `\M+nhhhh` (double-byte East Asian characters) to the characters they stand
/// for. Malformed escapes are left for the tokenizers, which drop the backslash and the code.
/// An escaped backslash (`\\U+0041`) is not an escape.
pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains("\\U+") && !s.contains("\\M+") {
return std::borrow::Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while let Some(&c) = chars.get(i) {
if c != '\\' {
out.push(c);
i += 1;
continue;
}
let (n, plus) = (chars.get(i + 1).copied(), chars.get(i + 2).copied());
if n == Some('U') && plus == Some('+') {
if let Some(unit) = hex4(&chars, i + 3) {
i += 7;
let low = |at: usize| {
(chars.get(at) == Some(&'\\') && chars.get(at + 1) == Some(&'U') && chars.get(at + 2) == Some(&'+'))
.then(|| hex4(&chars, at + 3))
.flatten()
.filter(|u| (0xDC00..0xE000).contains(u))
};
let ch = if (0xD800..0xDC00).contains(&unit) {
match low(i) {
Some(lo) => {
i += 7;
char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (lo - 0xDC00))
}
None => None,
}
} else {
char::from_u32(unit)
};
push_literal(&mut out, ch.unwrap_or('\u{fffd}'));
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}'));
i += 8;
continue;
}
// Any other backslash pair stays as it is (and an escaped backslash stays one).
out.push('\\');
i += 1;
if let Some(&next) = chars.get(i) {
out.push(next);
i += 1;
}
}
std::borrow::Cow::Owned(out)
}
fn arg(chars: &[char], from: usize) -> (String, usize) {
let mut j = from;
let mut a = String::new();
@@ -325,8 +236,6 @@ fn font_switch(a: &str) -> Option<TextFont> {
/// Parse MTEXT contents into words, spaces and paragraph breaks with their formats.
fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
let chars: Vec<char> = contents.chars().collect();
let h_min = base.height * 1e-3;
let h_max = if (base.height * 1e3).is_finite() { base.height * 1e3 } else { f64::MAX };
let mut fmts = vec![base];
let mut cur = 0usize;
let mut stack: Vec<usize> = Vec::new();
@@ -433,22 +342,14 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
None => (false, num(&a)),
};
if let Some(v) = v.filter(|v| *v > 0.0 && *v < 1e6) {
// Relative heights compound; keep the result within 1e-3..1e3 times the
// MTEXT's own height, never infinite.
derive(&mut fmts, &mut cur, &|f| f.height = if rel { (f.height * v).min(h_max).max(h_min) } else { v });
derive(&mut fmts, &mut cur, &|f| f.height = if rel { f.height * v } else { v });
}
}
'W' => {
let (a, n) = arg(&chars, i);
i = n;
// `\W2x;` multiplies the current factor (like `\H2x;`); `\W2;` sets it.
let (rel, v) = match a.trim().strip_suffix(['x', 'X']) {
Some(r) => (true, num(r)),
None => (false, num(&a)),
};
if let Some(v) = v.filter(|v| *v > 0.01 && *v < 100.0) {
// Relative factors compound; keep the result within the absolute range.
derive(&mut fmts, &mut cur, &|f| f.wf = if rel { (f.wf * v).clamp(0.01, 100.0) } else { v });
if let Some(v) = num(a.trim_end_matches(['x', 'X'])).filter(|v| *v > 0.01 && *v < 100.0) {
derive(&mut fmts, &mut cur, &|f| f.wf = v);
}
}
'Q' => {
@@ -480,13 +381,11 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
derive(&mut fmts, &mut cur, &|f| f.valign = v);
}
}
_ if takes_arg(code) => {
// \T and \p: argument up to ';'.
_ => {
// \T, \p and unknown codes: argument up to ';'.
let (_, n) = arg(&chars, i);
i = n;
}
// An unknown code has no argument: only the backslash and the code are dropped.
_ => {}
}
}
'{' => {
@@ -793,14 +692,11 @@ fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec<Line
/// Lay out MTEXT: inline formatting runs, stacked fractions, paragraphs, word wrap to the
/// column width and the attachment point.
pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
let contents = unescape_codes(contents);
let contents = contents.as_ref();
let h = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 };
let wf = if p.width_factor.is_finite() && p.width_factor > 0.0 { p.width_factor } else { 1.0 };
let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 };
let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 };
let oblique = if p.oblique.is_finite() { p.oblique } else { 0.0 };
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique, valign: 1 };
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 };
if para::needed(contents, p) {
return para::layout(contents, p, &base);
}
@@ -823,7 +719,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
for l in &mut lines {
l.reorder_bidi(bases.get(l.para).copied().flatten());
}
let ls = sane_line_spacing(p.line_spacing);
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
// Baselines below the top of the first line.
let mut baselines = Vec::with_capacity(lines.len());
let mut y = 0.0;
@@ -904,108 +800,7 @@ mod tests {
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
assert_eq!(plain_mtext("\\U+12"), "+12", "a short escape loses only the backslash and the code");
}
#[test]
fn unicode_escapes_are_decoded_in_every_parser() {
// The escape has no `;`, so it must not swallow the rest of the text.
assert_eq!(plain_mtext("A\\U+00B0 B C;D"), "A\u{b0} B C;D");
assert_eq!(lay("A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
// The paragraph path (`\p`) and the plain path agree.
assert_eq!(lay("\\pxql;A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
let scanned = para::Paragraphs::scan("\\U+00B0\\pi1;x\\U+0041 y;z");
assert!(scanned.formatted());
// A surrogate pair is one character; a lone surrogate is U+FFFD.
assert_eq!(plain_mtext("\\U+D83D\\U+DE00!"), "\u{1f600}!");
assert_eq!(lay("\\U+D83D\\U+DE00!", 0.0).lines, vec!["\u{1f600}!"]);
assert_eq!(plain_mtext("a\\U+D83Db"), "a\u{fffd}b");
assert_eq!(plain_mtext("a\\U+DE00b\\U+D83D"), "a\u{fffd}b\u{fffd}");
// A decoded backslash or brace is text, not markup; an escaped backslash is not an escape.
assert_eq!(plain_mtext("\\U+005CH2;x"), "\\H2;x");
assert_eq!(plain_mtext("\\\\U+0041"), "\\U+0041");
// `\M+n` double-byte characters, decoded like the DXF reader does.
assert_eq!(plain_mtext("\\M+5BCFE x;y"), "\u{4ef6} x;y");
// Malformed escapes lose only the backslash and the code.
assert_eq!(plain_mtext("a\\U+12 b"), "a+12 b");
assert_eq!(lay("a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
assert_eq!(lay("\\pxql;a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
assert_eq!(plain_mtext("a\\M+ b"), "a+ b");
}
#[test]
fn unknown_code_without_semicolon_keeps_the_text() {
assert_eq!(plain_mtext("A\\Zbc d"), "Abc d");
assert_eq!(lay("A\\Zbc d", 0.0).lines, vec!["Abc d"]);
assert_eq!(lay("\\pxql;A\\Zbc d", 0.0).lines, vec!["Abc d"]);
// A trailing lone backslash is dropped.
assert_eq!(plain_mtext("A\\"), "A");
// Codes with an argument still take it up to the `;`, or to the end.
assert_eq!(plain_mtext("A\\H2;B"), "AB");
assert_eq!(plain_mtext("A\\H2 B"), "A");
}
#[test]
fn non_finite_oblique_and_line_spacing_are_repaired() {
let run = |oblique: f64, spacing: f64| {
let p = MTextParams { oblique, line_spacing: spacing, ..MTextParams::new(Vec2::ZERO, 1.0) };
layout_mtext_with("AB\\PCD", &p)
};
let finite =
|l: &MTextLayout| l.bounds.min.x.is_finite() && l.bounds.max.x.is_finite() && l.bounds.min.y.is_finite() && l.bounds.max.y.is_finite();
let reference = run(0.0, 1.0);
for o in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let l = run(o, 1.0);
assert!(finite(&l), "oblique {o}");
assert_eq!(l.bounds, reference.bounds, "oblique {o}");
}
for s in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -2.0] {
let l = run(0.0, s);
assert!(finite(&l), "spacing {s}");
assert_eq!(l.bounds, reference.bounds, "spacing {s}");
}
// Spacing is clamped to AutoCAD's 0.25..=4 range.
assert_eq!(run(0.0, 1e9).bounds, run(0.0, 4.0).bounds);
assert_eq!(run(0.0, 1e-9).bounds, run(0.0, 0.25).bounds);
assert!(run(0.0, 4.0).bounds.height() > reference.bounds.height());
// The paragraph layout takes the same path.
let p = MTextParams { oblique: f64::NAN, line_spacing: f64::NAN, ..MTextParams::new(Vec2::ZERO, 1.0) };
assert!(finite(&layout_mtext_with("\\pxql;AB\\PCD", &p)));
}
#[test]
fn relative_heights_do_not_run_away() {
let src = format!("{}A", "\\H999999x;".repeat(60));
let l = lay(&src, 0.0);
assert!(l.bounds.height().is_finite() && l.bounds.width().is_finite());
assert!(l.bounds.height() <= 1e3 * 1.5, "{}", l.bounds.height());
assert!(l.bounds.height() > 100.0, "the glyph must not vanish: {}", l.bounds.height());
// Shrinking is bounded too.
let tiny = lay(&format!("{}A", "\\H0.000001x;".repeat(60)), 0.0);
assert!(tiny.bounds.height() > 0.0);
}
/// The width factor of the last format `parse` derived.
fn last_wf(contents: &str, base_wf: f64) -> f64 {
let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: base_wf, oblique: 0.0, valign: 1 };
parse(contents, base).1.last().map_or(f64::NAN, |f| f.wf)
}
#[test]
fn width_factor_x_is_relative() {
assert_eq!(last_wf("\\W2;a", 1.5), 2.0, "absolute sets the factor");
assert_eq!(last_wf("\\W2x;a", 1.5), 3.0, "x multiplies the current factor");
assert_eq!(last_wf("\\W2;\\W2x;a", 1.0), 4.0, "relative builds on the last absolute");
assert_eq!(last_wf("\\W0.5X;a", 1.0), 0.5, "capital X too");
// Hostile: a long relative chain stays inside the absolute range.
let chain = "\\W99x;".repeat(50);
assert_eq!(last_wf(&chain, 1.0), 100.0);
let down = "\\W0.02x;".repeat(50);
assert_eq!(last_wf(&down, 1.0), 0.01);
// Out of range or not a number: ignored.
assert_eq!(last_wf("\\W1e999x;a", 1.5), 1.5);
assert_eq!(last_wf("\\WNaNx;a", 1.5), 1.5);
assert_eq!(last_wf("\\Wx;a", 1.5), 1.5);
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
}
#[test]
+2 -5
View File
@@ -264,7 +264,6 @@ pub struct Paragraphs {
impl Paragraphs {
pub fn scan(contents: &str) -> Paragraphs {
let contents = super::unescape_codes(contents);
let chars: Vec<char> = contents.chars().collect();
let mut out = Paragraphs { states: vec![ParaProps::default()], ..Default::default() };
let mut cur = 0usize;
@@ -304,12 +303,10 @@ impl Paragraphs {
cur = out.states.len() - 1;
}
}
_ if super::takes_arg(code) => {
_ => {
let (_, n) = arg(&chars, i);
i = n;
}
// An unknown code has no argument: only the backslash and the code go.
_ => {}
}
}
'\n' => {
@@ -607,7 +604,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let lines = LineBuilder::new(&paras, &fmts, base, width, h).build(&atoms);
// Vertical placement: baselines below the top of each line's column.
let ls = super::sane_line_spacing(p.line_spacing);
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
let cols = p.columns.as_ref().filter(|_| width > 0.0);
let max_cols = match cols {
Some(c) if c.dynamic => MAX_COLUMNS,
+3 -168
View File
@@ -8,7 +8,7 @@
use std::f64::consts::FRAC_PI_2;
use crate::spline::{MAX_STACK_DEGREE, basis, find_span};
use crate::spline::{basis, find_span};
use crate::{EPS, Ellipse, Spline, TAU, Vec2};
/// Highest degree the editing operations work with.
@@ -88,34 +88,6 @@ fn elevate_bezier(p: &[H]) -> Vec<H> {
.collect()
}
/// Basis functions of degree `p` in `n[..=p]` and of degree `p - 1` in `lower[..p]` for `p >= 1`,
/// in one Cox-de Boor sweep: the degree `p - 1` values are the state just before the last step,
/// so they match [`basis`] exactly. `left` and `right` are scratch of `p + 1` values; the outputs
/// stay untouched when a slice is too short.
#[allow(clippy::too_many_arguments)]
fn basis_pair_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], lower: &mut [f64], left: &mut [f64], right: &mut [f64]) {
if p == 0 || n.len() <= p || lower.len() < p || left.len() <= p || right.len() <= p {
return;
}
n[0] = 1.0;
for j in 1..=p {
let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0);
left[j] = u - k(span as isize + 1 - j as isize);
right[j] = k(span as isize + j as isize) - u;
if j == p {
lower[..p].copy_from_slice(&n[..p]);
}
let mut saved = 0.0;
for r in 0..j {
let den = right[r + 1] + left[j - r];
let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 };
n[r] = saved + right[r + 1] * tmp;
saved = left[j - r] * tmp;
}
n[j] = saved;
}
}
impl Spline {
fn weight(&self, i: usize) -> f64 {
if self.weights.is_empty() { 1.0 } else { self.weights.get(i).copied().unwrap_or(1.0) }
@@ -158,8 +130,6 @@ impl Spline {
/// First derivative without re-validating the spline. The caller must have confirmed
/// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the
/// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407).
/// Up to degree [`MAX_STACK_DEGREE`] both bases come from one stack-only sweep; higher degrees
/// compute them on the heap.
fn derivative_unchecked(&self, t: f64) -> Vec2 {
let d = self.velocity(t);
if d.is_finite() { d } else { Vec2::ZERO }
@@ -172,17 +142,8 @@ impl Spline {
let (lo, hi) = self.domain();
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
let span = find_span(n, p, t, &self.knots);
let (mut sb, mut sd, mut sl, mut sr) =
([0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1]);
let (hb, hd);
let (nb, nd): (&[f64], &[f64]) = if (1..=MAX_STACK_DEGREE).contains(&p) {
basis_pair_into(span, t, p, &self.knots, &mut sb, &mut sd, &mut sl, &mut sr);
(&sb, &sd)
} else {
hb = basis(span, t, p, &self.knots);
hd = basis(span, t, p - 1, &self.knots);
(&hb, &hd)
};
let nb = basis(span, t, p, &self.knots);
let nd = basis(span, t, p - 1, &self.knots);
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
@@ -663,132 +624,6 @@ mod tests {
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0), Vec2::new(12.0, 2.0)], 3)
}
/// The previous implementation (two heap-allocated bases), kept as the oracle for the
/// allocation-free `derivative_unchecked`.
impl Spline {
fn derivative_unchecked_old(&self, t: f64) -> Vec2 {
let p = self.degree;
let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO };
let (lo, hi) = self.domain();
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
let span = find_span(n, p, t, &self.knots);
let nb = basis(span, t, p, &self.knots);
let nd = basis(span, t, p - 1, &self.knots);
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
for j in 0..=p {
let Some(i) = (span + j).checked_sub(p) else { continue };
let Some(c) = self.control.get(i) else { continue };
let wi = self.weight(i);
let n0 = nb.get(j).copied().unwrap_or(0.0);
// N'_{i,p} = p (N_{i,p-1} / (u_{i+p} - u_i) - N_{i+1,p-1} / (u_{i+p+1} - u_{i+1})).
let left = if j >= 1 { ratio(nd.get(j - 1), k(i + p) - k(i)) } else { 0.0 };
let right = if j < p { ratio(nd.get(j), k(i + p + 1) - k(i + 1)) } else { 0.0 };
let dn = p as f64 * (left - right);
a += *c * (n0 * wi);
w += n0 * wi;
da += *c * (dn * wi);
dw += dn * wi;
}
if w.abs() < 1e-300 {
return Vec2::ZERO;
}
let d = (da - a / w * dw) / w;
if d.is_finite() { d } else { Vec2::ZERO }
}
}
struct XorShift(u64);
impl XorShift {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
/// A valid random spline of degree `p`: clamped or unclamped knots, repeated interior knots,
/// and non-rational, rational or zero/negative weights.
fn random_spline(r: &mut XorShift, p: usize) -> Spline {
let n = p + 1 + r.below(8);
let control = (0..n).map(|_| Vec2::new(r.unit() * 100.0 - 50.0, r.unit() * 100.0 - 50.0)).collect();
let total = n + p + 1;
let mut knots: Vec<f64> =
(0..total).map(|_| (r.unit() * 8.0).floor() * if r.below(3) == 0 { 0.5 } else { 1.0 } + r.unit() * f64::from(r.below(2) == 0)).collect();
knots.sort_by(f64::total_cmp);
if r.below(2) == 0 {
// Clamp both ends.
let (lo, hi) = (knots[p], knots[n]);
knots[..=p].fill(lo);
knots[n..].fill(hi);
}
let weights = match r.below(4) {
0 => Vec::new(),
1 => (0..n).map(|_| 0.2 + r.unit() * 4.0).collect(),
2 => (0..n).map(|_| if r.below(5) == 0 { 0.0 } else { r.unit() * 2.0 - 0.5 }).collect(),
_ => (0..n).map(|_| 1.0 + r.unit() * 1e-9).collect(),
};
Spline { degree: p, knots, control, weights, ..Spline::default() }
}
fn bits(v: Vec2) -> (u64, u64) {
(v.x.to_bits(), v.y.to_bits())
}
#[test]
fn derivative_is_bit_identical_to_the_heap_implementation() {
let mut r = XorShift(0x9E37_79B9_7F4A_7C15);
let mut zero_results = 0usize;
for p in 1..=12usize {
let mut compared = 0usize;
for _ in 0..400 {
let s = random_spline(&mut r, p);
if !s.is_valid() {
continue;
}
let (lo, hi) = s.domain();
let mut ts = vec![lo, hi, 0.5 * (lo + hi), lo - 1.0, hi + 1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
ts.extend(s.knots.iter().copied());
ts.extend(s.knots.iter().map(|k| k.next_up()));
ts.extend(s.knots.iter().map(|k| k.next_down()));
ts.extend((0..8).map(|_| lo + (hi - lo) * r.unit()));
for t in ts {
let (new, old) = (s.derivative_unchecked(t), s.derivative_unchecked_old(t));
assert_eq!(bits(new), bits(old), "degree {p}, t = {t}, knots {:?}, weights {:?}", s.knots, s.weights);
compared += 1;
zero_results += usize::from(old == Vec2::ZERO);
}
}
assert!(compared > 1000, "degree {p}: only {compared} comparisons ran");
}
// The zero-vector exits (vanishing weight sum, non-finite result) are exercised too.
assert!(zero_results > 0);
}
#[test]
fn derivative_dispatch_around_the_stack_limit_does_not_change_results() {
// The last stack degree and the first heap degree both match the oracle on a clamped
// uniform spline (the heap degree runs the unchanged old path; this checks the dispatch).
for p in [MAX_STACK_DEGREE, MAX_STACK_DEGREE + 1] {
let ctrl: Vec<Vec2> = (0..p + 4).map(|i| Vec2::new(i as f64, (i * i % 7) as f64)).collect();
let s = Spline::from_control(ctrl, p);
assert_eq!(s.degree, p);
let (lo, hi) = s.domain();
for i in 0..=50 {
let t = lo + (hi - lo) * f64::from(i) / 50.0;
assert_eq!(bits(s.derivative_unchecked(t)), bits(s.derivative_unchecked_old(t)), "degree {p}, t = {t}");
}
}
}
#[test]
fn derivative_matches_finite_differences() {
let mut s = cubic();
+62 -283
View File
@@ -75,108 +75,7 @@ fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize
}
}
/// Upper bound on candidate-pair *visits*. Every visit counts, including repeats of a pair that shares several grid
/// cells, not only the distinct pairs actually tested. A long segment replicated across many
/// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
/// repeated work unbounded (CWE-407).
const MAX_PAIR_VISITS: u64 = 60_000_000;
/// Upper bound on distinct segment pairs tested for a crossing.
const MAX_PAIRS_TESTED: usize = 20_000_000;
/// Split parameters of every segment (always `0` and `1`, plus every crossing), found with a uniform grid.
fn pair_splits(segs: &[(Vec2, Vec2)], bb: &Bounds2) -> Option<Vec<Vec<f64>>> {
pair_splits_limited(segs, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED)
}
/// `pair_splits` with explicit budgets: `None` once more than `max_visits` candidate pairs were visited
/// or more than `max_pairs` distinct pairs were tested.
fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option<Vec<Vec<f64>>> {
let n = segs.len();
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
let cw = (bb.width() / cells as f64).max(1e-12);
let ch = (bb.height() / cells as f64).max(1e-12);
let cell = |p: Vec2| {
(
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
)
};
// Cell lists in a flat grid, indexed `gx * cells + gy`; each list is in ascending segment order.
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); cells * cells];
// The lowest cell of each segment's cell range; the lowest cell two segments share is the
// componentwise maximum of their two.
let mut low: Vec<(usize, usize)> = vec![(0, 0); n];
let mut is_long = vec![false; n];
let mut long: Vec<usize> = Vec::new();
for (i, (a, b)) in segs.iter().enumerate() {
let (x0, y0) = cell(a.min(*b));
let (x1, y1) = cell(a.max(*b));
if let Some(l) = low.get_mut(i) {
*l = (x0, y0);
}
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
// Very long segment: too many cells to register in; it is tested against every segment below.
long.push(i);
if let Some(f) = is_long.get_mut(i) {
*f = true;
}
continue;
}
for gx in x0..=x1 {
for gy in y0..=y1 {
if let Some(list) = grid.get_mut(gx * cells + gy) {
list.push(i);
}
}
}
}
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
let mut tested: usize = 0;
let mut visits: u64 = 0;
for (c, list) in grid.iter().enumerate() {
let (gx, gy) = (c / cells, c % cells);
for (k, &i) in list.iter().enumerate() {
let Some(&(ix, iy)) = low.get(i) else { continue };
for &j in list.iter().skip(k + 1) {
visits += 1;
if visits > max_visits {
return None;
}
let Some(&(jx, jy)) = low.get(j) else { continue };
// A pair sharing several cells is tested once, in the lowest cell both occupy.
if i == j || gx != ix.max(jx) || gy != iy.max(jy) {
continue;
}
split_pair(segs, &mut splits, i, j);
tested += 1;
if tested > max_pairs {
return None;
}
}
}
}
// A long segment is in no cell list. Two long segments are tested once, from the lower index.
for &i in &long {
for j in 0..n {
if i == j || (j < i && is_long.get(j).copied().unwrap_or(false)) {
continue;
}
split_pair(segs, &mut splits, i, j);
tested += 1;
if tested > max_pairs {
return None;
}
}
}
Some(splits)
}
fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
build_with(polys, tol, pair_splits)
}
/// `build` with the crossing search injected, so tests can run the old and the new search on the same input.
fn build_with(polys: &[Vec<Vec2>], tol: f64, find_splits: impl Fn(&[(Vec2, Vec2)], &Bounds2) -> Option<Vec<Vec<f64>>>) -> Option<Graph> {
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
let mut owner: Vec<usize> = Vec::new();
for (k, pl) in polys.iter().enumerate() {
@@ -197,9 +96,70 @@ fn build_with(polys: &[Vec<Vec2>], tol: f64, find_splits: impl Fn(&[(Vec2, Vec2)
if segs.is_empty() || segs.len() > MAX_SEGMENTS {
return None;
}
// Split points per segment (parameters), found with a uniform grid.
let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]));
let n = segs.len();
let splits = find_splits(&segs, &bb)?;
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
let cw = (bb.width() / cells as f64).max(1e-12);
let ch = (bb.height() / cells as f64).max(1e-12);
let cell = |p: Vec2| {
(
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
)
};
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
let mut long: Vec<usize> = Vec::new();
for (i, (a, b)) in segs.iter().enumerate() {
let (x0, y0) = cell(a.min(*b));
let (x1, y1) = cell(a.max(*b));
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
// Very long segment: too many cells to register in; it is tested against every segment below.
long.push(i);
continue;
}
for gx in x0..=x1 {
for gy in y0..=y1 {
grid.entry((gx, gy)).or_default().push(i);
}
}
}
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
let mut tested = std::collections::HashSet::new();
// Count every candidate-pair *visit*, including repeats of a pair that shares several grid
// cells, not only the distinct pairs actually tested. A long segment replicated across many
// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
// repeated hashing unbounded (CWE-407).
let mut visits: u64 = 0;
const MAX_PAIR_VISITS: u64 = 60_000_000;
for list in grid.values() {
for (k, &i) in list.iter().enumerate() {
for &j in list.iter().skip(k + 1) {
visits += 1;
if visits > MAX_PAIR_VISITS {
return None;
}
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
split_pair(&segs, &mut splits, i, j);
if tested.len() > 20_000_000 {
return None;
}
}
}
}
for &i in &long {
for j in 0..n {
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
split_pair(&segs, &mut splits, i, j);
if tested.len() > 20_000_000 {
return None;
}
}
}
let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() };
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
@@ -670,187 +630,6 @@ mod tests {
vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)]
}
/// The crossing search before the lowest-shared-cell rule: a `HashSet` of tested pairs over a `HashMap` grid.
fn pair_splits_oracle(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option<Vec<Vec<f64>>> {
let n = segs.len();
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
let cw = (bb.width() / cells as f64).max(1e-12);
let ch = (bb.height() / cells as f64).max(1e-12);
let cell = |p: Vec2| {
(
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
)
};
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
let mut long: Vec<usize> = Vec::new();
for (i, (a, b)) in segs.iter().enumerate() {
let (x0, y0) = cell(a.min(*b));
let (x1, y1) = cell(a.max(*b));
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
long.push(i);
continue;
}
for gx in x0..=x1 {
for gy in y0..=y1 {
grid.entry((gx, gy)).or_default().push(i);
}
}
}
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
let mut tested = std::collections::HashSet::new();
let mut visits: u64 = 0;
for list in grid.values() {
for (k, &i) in list.iter().enumerate() {
for &j in list.iter().skip(k + 1) {
visits += 1;
if visits > max_visits {
return None;
}
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
split_pair(segs, &mut splits, i, j);
if tested.len() > max_pairs {
return None;
}
}
}
}
for &i in &long {
for j in 0..n {
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
split_pair(segs, &mut splits, i, j);
if tested.len() > max_pairs {
return None;
}
}
}
Some(splits)
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
}
/// Random segments: short ones, some long ones, and some on a coarse lattice (shared ends, overlaps).
fn random_segments(r: &mut Rng, n: usize, size: f64, long_every: u64) -> Vec<(Vec2, Vec2)> {
let mut out = Vec::new();
for _ in 0..n {
let a = Vec2::new(r.unit() * size, r.unit() * size);
let seg = match r.next() % 4 {
0 if long_every > 0 && r.next().is_multiple_of(long_every) => (
Vec2::new(r.unit() * size * 0.02, r.unit() * size * 0.02),
Vec2::new(size * (0.98 + r.unit() * 0.02), size * (0.98 + r.unit() * 0.02)),
),
1 => {
let q = |r: &mut Rng| (r.next() % 9) as f64 * size / 8.0;
(Vec2::new(q(r), q(r)), Vec2::new(q(r), q(r)))
}
_ => (a, a + Vec2::new((r.unit() - 0.5) * size * 0.1, (r.unit() - 0.5) * size * 0.1)),
};
if !seg.0.near(seg.1, 1e-9) {
out.push(seg);
}
}
out
}
fn sorted_bits(splits: &[Vec<f64>]) -> Vec<Vec<u64>> {
splits
.iter()
.map(|v| {
let mut v = v.clone();
v.sort_by(f64::total_cmp);
v.iter().map(|x| x.to_bits()).collect()
})
.collect()
}
fn bounds_of(segs: &[(Vec2, Vec2)]) -> Bounds2 {
Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]))
}
fn assert_same_search(segs: &[(Vec2, Vec2)], max_visits: u64, max_pairs: usize) {
let bb = bounds_of(segs);
let old = pair_splits_oracle(segs, &bb, max_visits, max_pairs);
let new = pair_splits_limited(segs, &bb, max_visits, max_pairs);
assert_eq!(old.as_deref().map(sorted_bits), new.as_deref().map(sorted_bits), "n={} visits<={max_visits} pairs<={max_pairs}", segs.len());
}
#[test]
fn pair_search_matches_oracle_with_budgets() {
// Small inputs in few cells: many pairs share several cells. Sweeping each budget over
// 0..=400 crosses its exhaustion point for most seeds, including the exact boundary.
for seed in 1..=150u64 {
let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
let n = 2 + (r.next() % 28) as usize;
let segs = random_segments(&mut r, n, 100.0, 0);
if segs.is_empty() {
continue;
}
for v in 0..=400u64 {
assert_same_search(&segs, v, usize::MAX);
}
for p in 0..=400usize {
assert_same_search(&segs, u64::MAX, p);
}
assert_same_search(&segs, 60_000_000, 20_000_000);
}
}
#[test]
fn pair_search_matches_oracle_with_long_segments() {
// Around 5000 segments give a 71x71 grid, so a diagonal covers more than 4096 cells and takes the long path.
for seed in 1..=3u64 {
let mut r = Rng(seed.wrapping_mul(0xD1B5_4A32_D192_ED03) | 1);
let n = 5_000 + (r.next() % 500) as usize;
let segs = random_segments(&mut r, n, 1000.0, 40);
let bb = bounds_of(&segs);
let cells = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 512);
assert!(cells >= 64, "grid too coarse to reach the long path");
assert_same_search(&segs, 60_000_000, 20_000_000);
// Budgets exhausted in the cell phase (visits, then pairs).
assert_same_search(&segs, 3_000, 20_000_000);
assert_same_search(&segs, 60_000_000, 200);
assert!(pair_splits_limited(&segs, &bb, 3_000, 20_000_000).is_none());
assert!(pair_splits_limited(&segs, &bb, 60_000_000, 200).is_none());
let (cw, ch) = ((bb.width() / cells as f64).max(1e-12), (bb.height() / cells as f64).max(1e-12));
let span = |a: f64, b: f64, o: f64, w: f64| {
(((b.max(a) - o) / w).floor().min((cells - 1) as f64) - ((b.min(a) - o) / w).floor().max(0.0) + 1.0) as usize
};
let long = segs.iter().filter(|(a, b)| span(a.x, b.x, bb.min.x, cw) * span(a.y, b.y, bb.min.y, ch) > 4096).count();
assert!(long > 0, "seed {seed}: no segment takes the long path");
}
}
#[test]
fn build_matches_oracle_on_random_drawings() {
for seed in 1..=60u64 {
let mut r = Rng(seed.wrapping_mul(0xA076_1D64_78BD_642F) | 1);
let n = 5 + (r.next() % 400) as usize;
let polys: Vec<Vec<Vec2>> = random_segments(&mut r, n, 100.0, 0).into_iter().map(|(a, b)| vec![a, b]).collect();
let old = build_with(&polys, 1e-6, |s, bb| pair_splits_oracle(s, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED));
let new = build(&polys, 1e-6);
let (Some(old), Some(new)) = (old, new) else { panic!("seed {seed}: build failed") };
assert_eq!(old.pts, new.pts, "seed {seed}");
assert_eq!(old.adj, new.adj, "seed {seed}");
assert_eq!(old.src, new.src, "seed {seed}");
assert_eq!(old.edge_src, new.edge_src, "seed {seed}");
}
}
#[test]
fn long_segment_crossing_in_unsampled_cell_is_split() {
// A diagonal long enough to skip the grid, and a short segment crossing it in a cell the old coarse sampling missed.
+5 -53
View File
@@ -347,64 +347,21 @@ fn basis_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], left:
}
}
/// Gaussian elimination with partial pivoting. The interpolation and least-squares matrices are
/// banded, so every loop is limited to the band (lower width `kl`, upper width `ku`, measured from
/// the matrix; partial pivoting widens the upper band to at most `kl + ku`). The skipped operations
/// only combine entries that are exactly `+0.0` (they stay `+0.0` through the full sweep), so the
/// result is bit for bit that of the full sweep (for finite input that does not overflow). A matrix with full bandwidth (the cyclic matrix of
/// a closed fit) costs what it did before.
///
/// A skipped `0 * x` is not zero when `x` is NaN or infinite, so a matrix or right-hand side with
/// NaN, infinity or magnitudes above `1e100` (which could overflow) takes the full sweep, and so
/// does a back-substitution row whose banded sum is zero (skipped zero terms can flip its sign).
/// Gaussian elimination with partial pivoting.
#[allow(clippy::needless_range_loop)]
pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
#[cfg(test)]
if tests::DENSE_ORACLE.get() {
return tests::solve_dense(a, b);
}
let n = b.len();
// Per row: how far its non-zero entries reach left and right of the diagonal, and whether one
// is NaN, infinite or large enough to overflow.
let measure = |r: usize, row: &[f64]| {
// Bit test, not `!= 0.0`: a stored `-0.0` is not the `+0.0` the skipped entries rely on.
let row = row.get(..n).unwrap_or(row);
let Some(first) = row.iter().position(|v| v.to_bits() != 0) else { return (0, 0, false) };
let last = row.iter().rposition(|v| v.to_bits() != 0).unwrap_or(first);
let wild = row.get(first..=last).is_some_and(|band| band.iter().any(|v| v.is_nan() || v.abs() > 1e100));
(r.saturating_sub(first), last.saturating_sub(r), wild)
};
let join = |x: (usize, usize, bool), m: (usize, usize, bool)| (x.0.max(m.0), x.1.max(m.1), x.2 | m.2);
// A last row that reaches column 0 (the cyclic matrix of a closed fit) makes every range of the
// sweep full-width whatever the other rows hold, so they need no measuring.
let mut m = (0usize, 0usize, false);
for r in [0, n.saturating_sub(1)] {
if let Some(row) = a.get(r) {
m = join(m, measure(r, row));
}
}
// (kl = n - 1 widens every range to the full matrix, so the unmeasured rows cannot matter.)
if m.0 + 1 < n {
m = a.iter().enumerate().take(n).fold(m, |x, (r, row)| join(x, measure(r, row)));
}
let (mut kl, mut ku, wild) = m;
if wild || b.iter().any(|v| v.is_nan() || v.abs() > 1e100) {
(kl, ku) = (n, n);
}
let kw = ku + kl;
for c in 0..n {
let end = (c + kl).min(n - 1) + 1;
let piv = (c..end).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
if a[piv][c].abs() < 1e-14 {
return None;
}
a.swap(c, piv);
b.swap(c, piv);
let kend = (c + kw).min(n - 1) + 1;
for r in c + 1..end {
for r in c + 1..n {
let f = a[r][c] / a[c][c];
if f != 0.0 {
for k in c..kend {
for k in c..n {
a[r][k] -= f * a[c][k];
}
b[r] -= f * b[c];
@@ -413,12 +370,7 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
}
let mut x = vec![0.0; n];
for r in (0..n).rev() {
let end = (r + kw).min(n - 1) + 1;
let mut s: f64 = (r + 1..end).map(|k| a[r][k] * x[k]).sum();
if s == 0.0 && end < n {
// The skipped terms are signed zeros; added to a zero they can flip its sign.
s = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
}
let s: f64 = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
x[r] = (b[r] - s) / a[r][r];
}
Some(x)
-193
View File
@@ -380,196 +380,3 @@ fn a_normal_low_degree_spline_is_valid() {
assert!(s.is_valid());
assert!(s.tessellate(1e-3).len() > 4);
}
thread_local! {
/// While set, `solve` runs `solve_dense` (this thread only, so parallel tests do not mix).
pub(super) static DENSE_ORACLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// `solve` before it was limited to the matrix band, kept as the reference.
#[allow(clippy::needless_range_loop)]
pub(super) fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
let n = b.len();
for c in 0..n {
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
if a[piv][c].abs() < 1e-14 {
return None;
}
a.swap(c, piv);
b.swap(c, piv);
for r in c + 1..n {
let f = a[r][c] / a[c][c];
if f != 0.0 {
for k in c..n {
a[r][k] -= f * a[c][k];
}
b[r] -= f * b[c];
}
}
}
let mut x = vec![0.0; n];
for r in (0..n).rev() {
let s: f64 = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
x[r] = (b[r] - s) / a[r][r];
}
Some(x)
}
fn bits(v: &[f64]) -> Vec<u64> {
v.iter().map(|x| x.to_bits()).collect()
}
fn bits2(v: &[Vec2]) -> Vec<u64> {
v.iter().flat_map(|p| [p.x.to_bits(), p.y.to_bits()]).collect()
}
/// Both solvers on one system: `None` together, or the same bits.
fn assert_same_solution(a: &[Vec<f64>], b: &[f64], what: &str) {
let old = solve_dense(a.to_vec(), b.to_vec());
let new = solve(a.to_vec(), b.to_vec());
assert_eq!(old.as_deref().map(bits), new.as_deref().map(bits), "{what}");
}
/// A random matrix with `kl` sub- and `ku` super-diagonals (some entries exactly zero).
fn banded(n: usize, kl: usize, ku: usize, seed: &mut u64) -> Vec<Vec<f64>> {
let mut a = vec![vec![0.0; n]; n];
for (r, row) in a.iter_mut().enumerate() {
for (c, v) in row.iter_mut().enumerate() {
if c + kl >= r && c <= r + ku && noise(seed) > 0.15 {
*v = noise(seed) * 4.0 - 2.0;
}
}
}
a
}
#[test]
fn banded_solve_matches_the_dense_sweep_bit_for_bit() {
let mut seed = 0x5eed_u64;
for n in [1, 2, 3, 4, 5, 8, 17, 40, 120] {
for (kl, ku) in [(0, 0), (1, 0), (0, 2), (1, 1), (3, 3), (2, 5), (n, n)] {
for _ in 0..6 {
let a = banded(n, kl, ku, &mut seed);
// Right-hand sides with zeros of both signs make the zero-sign cases reachable.
let b: Vec<f64> = (0..n)
.map(|i| [noise(&mut seed) * 9.0 - 4.0, 0.0, -0.0][(noise(&mut seed) * 3.0) as usize % 3] * (i % 4 != 0) as u8 as f64)
.collect();
assert_same_solution(&a, &b, &format!("n={n} kl={kl} ku={ku}"));
assert_same_solution(&a, &vec![0.0; n], &format!("zero rhs n={n} kl={kl} ku={ku}"));
assert_same_solution(&a, &vec![-0.0; n], &format!("negative-zero rhs n={n} kl={kl} ku={ku}"));
}
}
}
}
#[test]
fn solve_handles_negative_zeros_signed_zero_sums_and_hostile_values() {
let mut seed = 77_u64;
for n in [3, 6, 30] {
let mut a = banded(n, 1, 2, &mut seed);
// A stored `-0.0` outside the band is still part of the matrix.
a[0][n - 1] = -0.0;
a[n - 1][0] = -0.0;
let b: Vec<f64> = (0..n).map(|i| if i % 2 == 0 { -0.0 } else { 1.0 }).collect();
assert_same_solution(&a, &b, &format!("negative zeros n={n}"));
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e200, 1e101] {
let mut ab = a.clone();
ab[n / 2][n / 2] = bad;
assert_same_solution(&ab, &b, &format!("{bad:?} in the matrix, n={n}"));
let mut bb = b.clone();
bb[n - 1] = bad;
assert_same_solution(&a, &bb, &format!("{bad:?} in the rhs, n={n}"));
}
}
// Bad values in a narrow band with no corner entry, so the guard (not a full-width matrix) decides.
for bad in [f64::NAN, f64::INFINITY, 1e200, 1e101] {
let a = banded(12, 1, 2, &mut seed);
let mut ab = a.clone();
ab[5][5] = bad;
ab[5][6] = 1.0;
assert_same_solution(&ab, &[1.0; 12], &format!("{bad:?} in a narrow band"));
let mut b = vec![1.0; 12];
b[3] = bad;
assert_same_solution(&a, &b, &format!("{bad:?} in the rhs of a narrow band"));
}
// Singular and empty systems.
assert_same_solution(&[vec![0.0, 0.0], vec![0.0, 1.0]], &[1.0, 1.0], "singular");
assert_same_solution(&[], &[], "empty");
assert_eq!(solve(Vec::new(), Vec::new()), Some(Vec::new()));
}
/// Fit points: a wavy line with noise; every `zero_every`-th coordinate is exactly zero.
fn fit_points(n: usize, seed: &mut u64, zero_every: usize) -> Vec<Vec2> {
(0..n)
.map(|i| {
let x = i as f64 * 3.0 + noise(seed);
let y = (i as f64 * 0.3).sin() * 50.0 + noise(seed) * 5.0;
if zero_every > 0 && i % zero_every == 0 { Vec2::new(x, 0.0) } else { Vec2::new(x, y) }
})
.collect()
}
fn spline_bits(s: &Spline) -> (Vec<u64>, Vec<u64>, Vec<u64>, Vec<u64>, usize) {
(bits(&s.knots), bits2(&s.control), bits(&s.weights), bits2(&s.fit), s.degree)
}
/// Fit with the banded `solve`, then with the dense reference, and compare every bit.
/// Clears `DENSE_ORACLE` when dropped, so a failing assertion cannot leave it set.
struct OracleOff;
impl Drop for OracleOff {
fn drop(&mut self) {
DENSE_ORACLE.set(false);
}
}
fn assert_fit_unchanged(fit: &[Vec2], closed: bool, opts: FitOptions, what: &str) {
let _off = OracleOff;
let new = Spline::fit_with(fit, closed, opts);
DENSE_ORACLE.set(true);
let old = Spline::fit_with(fit, closed, opts);
DENSE_ORACLE.set(false);
assert_eq!(spline_bits(&old), spline_bits(&new), "{what}");
}
#[test]
fn fits_are_bit_identical_to_the_dense_solver() {
let mut seed = 2432_u64;
let tolerant = FitOptions { tolerance: 0.5, ..FitOptions::default() };
let tangents = FitOptions { start_tangent: Some(Vec2::new(0.0, 1.0)), end_tangent: Some(Vec2::new(1.0, -1.0)), ..FitOptions::default() };
let chord = FitOptions { knots: crate::KnotParam::Chord, ..FitOptions::default() };
for n in [3, 4, 5, 7, 12, 30, 100] {
for round in 0..4 {
let zero_every = [0, 2, 3, 1][round];
let pts = fit_points(n, &mut seed, zero_every);
for closed in [false, true] {
for (name, opts) in [("default", FitOptions::default()), ("tolerance", tolerant), ("tangents", tangents), ("chord", chord)] {
assert_fit_unchanged(&pts, closed, opts, &format!("n={n} closed={closed} {name} zero_every={zero_every}"));
}
}
}
}
// Large point sets: open interpolation (banded) and a closed one (full bandwidth), and a
// tolerance fit at the size the approximation search still accepts.
let big = fit_points(1000, &mut seed, 0);
assert_fit_unchanged(&big, false, FitOptions::default(), "n=1000 open");
assert_fit_unchanged(&big[..150], true, FitOptions::default(), "n=150 closed");
assert_fit_unchanged(&big[..300], false, tolerant, "n=300 tolerance");
// Repeated points are kinks, which fit each piece on its own.
let mut kinked = fit_points(40, &mut seed, 0);
kinked.insert(10, kinked[10]);
kinked.insert(25, kinked[25]);
assert_fit_unchanged(&kinked, false, FitOptions::default(), "kinks");
}
#[test]
fn the_cyclic_matrix_of_a_closed_fit_keeps_the_full_sweep() {
// Corner entries give the closed matrix a full bandwidth; it must still agree with the dense sweep.
let mut seed = 9_u64;
for n in [4, 9, 60] {
let mut a = banded(n, 1, 1, &mut seed);
a[0][n - 1] = 0.7;
a[n - 1][0] = -0.3;
let b: Vec<f64> = (0..n).map(|_| noise(&mut seed)).collect();
assert_same_solution(&a, &b, &format!("cyclic n={n}"));
}
}
+3 -6
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-dwg = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-fonts = { workspace = true }
@@ -25,11 +25,8 @@ quick-xml = { workspace = true }
image = { workspace = true }
[dev-dependencies]
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
acadrust = "0.6.6"
tiny-skia = { workspace = true }
# Reads plotted PDFs back.
craft-pdf = { workspace = true, features = ["read"] }
# 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;
+73 -219
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),
@@ -417,11 +417,10 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
}
}
if proxies {
f.complete_class_table = classes.iter().filter(|(k, _, _)| k == "CLASS").count() <= MAX_OBJECTS;
f.class_order = class_order;
}
for (kind, _, t) in &classes {
if kind == "CLASS" && (f.complete_class_table || val(t, 1).is_some_and(|n| types.contains(&up(&n)))) && f.classes.len() < MAX_OBJECTS {
if kind == "CLASS" && val(t, 1).is_some_and(|n| types.contains(&up(&n))) && f.classes.len() < MAX_OBJECTS {
f.classes.push(t.iter().map(raw).collect());
}
}
@@ -518,108 +517,44 @@ fn retained_objects(
// hundreds of thousands of groups) would be read once per child (#862).
let mut xdict_memo: HashMap<usize, bool> = HashMap::new();
let mut is_xdict = |index: usize| *xdict_memo.entry(index).or_insert_with(|| is_xdict(index));
let hard = hard_owners(objects.iter().filter_map(|(_, _, t)| Some((head(t).handle?, held(t)))));
// One pass with `kept` the objects an earlier pass kept: a null-owner object goes with the
// first of its hard owners that is kept.
let mut pass = |kept: &HashMap<usize, bool>| {
// Previously live objects already have a proved path to a retained owner. Seed that
// proof before traversal, so mutual hard owners cannot hide it from their children.
let mut retained: HashMap<usize, bool> = kept.iter().filter(|(_, live)| **live).map(|(&i, &live)| (i, live)).collect();
let kept = |h: &str| by_handle.get(h).and_then(|i| kept.get(i)) == Some(&true);
for index in 0..objects.len() {
let mut path = Vec::new();
let mut current = index;
let keep = loop {
if let Some(&known) = retained.get(&current) {
break known;
}
if path.contains(&current) || path.len() >= MAX_DEPTH {
let mut retained = HashMap::new();
for index in 0..objects.len() {
let mut path = Vec::new();
let mut current = index;
let keep = loop {
if let Some(&known) = retained.get(&current) {
break known;
}
if path.contains(&current) || path.len() >= MAX_DEPTH {
break false;
}
path.push(current);
let Some((_, _, tags)) = objects.get(current) else { break false };
let header = head(tags);
let (Some(handle), Some(owner)) = (header.handle, header.owner) else { break false };
if by_handle.get(&handle) != Some(&current) {
break false;
}
if root == Some(owner.as_str()) {
break root_key(&handle).is_some_and(|key| !modelled_key(key));
}
if let Some(&parent) = by_handle.get(&owner) {
if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) {
break false;
}
path.push(current);
let Some((_, _, tags)) = objects.get(current) else { break false };
let header = head(tags);
let Some(handle) = header.handle else { break false };
let Some(owner) = owner_of(header.owner, &handle, &hard, kept) else { break false };
if by_handle.get(&handle) != Some(&current) {
break false;
}
if root == Some(owner.as_str()) {
break root_key(&handle).is_some_and(|key| !modelled_key(key));
}
if let Some(&parent) = by_handle.get(&owner) {
if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) {
break false;
}
current = parent;
continue;
}
if is_xdict(current) {
break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str()));
}
break entities.get(&owner) == Some(&true);
};
for visited in path {
retained.insert(visited, keep);
current = parent;
continue;
}
}
retained
};
settle(&hard, |kept| pass(kept))
}
/// Run `pass` until what it keeps settles. The first pass sees nothing kept, so a null-owner
/// object goes with its first hard owner; while one of its other hard owners is kept and that
/// one is not, the next pass moves it there. A file where no object has two hard owners takes
/// one pass; otherwise it stops when a pass changes nothing, after at most [`MAX_DEPTH`] more.
fn settle<K: Eq + std::hash::Hash>(
hard: &HashMap<String, Vec<String>>,
mut pass: impl FnMut(&HashMap<K, bool>) -> HashMap<K, bool>,
) -> HashMap<K, bool> {
let mut kept = pass(&HashMap::new());
if hard.values().all(|owners| owners.len() < 2) {
return kept;
}
for _ in 0..MAX_DEPTH {
let next = pass(&kept);
if next == kept {
break;
}
kept = next;
}
kept
}
/// The handles an object names by a hard owner reference (group 360).
fn held(tags: &[Tag]) -> impl Iterator<Item = String> + '_ {
tags.iter().take_while(|g| g.code < 1000).filter(|g| g.code == 360).map(|g| g.str())
}
/// The objects that own each object by a hard owner reference (group 360), in file order:
/// `child handle → owner handles`.
fn hard_owners<S: AsRef<str>>(objects: impl Iterator<Item = (String, impl Iterator<Item = S>)>) -> HashMap<String, Vec<String>> {
// Membership is hashed, while the vector keeps file order. A long list of distinct
// holders for one child must not scan all earlier holders once per reference.
let mut out: HashMap<String, (Vec<String>, HashSet<String>)> = HashMap::new();
for (h, children) in objects {
for c in children {
let (owners, seen) = out.entry(norm(c.as_ref())).or_default();
if seen.insert(h.clone()) {
owners.push(h.clone());
if is_xdict(current) {
break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str()));
}
break entities.get(&owner) == Some(&true);
};
for visited in path {
retained.insert(visited, keep);
}
}
out.into_iter().map(|(child, (owners, _))| (child, owners)).collect()
}
/// An object's owner: its group 330, or for one whose owner is null (another application's
/// proxy that another proxy holds) the object that hard-owns it ([`hard_owners`]): the first
/// one that is `kept`, or else the first one.
fn owner_of(owner: Option<String>, handle: &str, hard: &HashMap<String, Vec<String>>, kept: impl Fn(&str) -> bool) -> Option<String> {
owner.filter(|o| o != "0").or_else(|| {
let owners = hard.get(handle)?;
owners.iter().find(|o| kept(o)).or_else(|| owners.first()).cloned()
})
retained
}
/// Proxy records (DXF Reference, ACAD_PROXY_OBJECT and ACAD_PROXY_ENTITY): another application's
@@ -706,8 +641,6 @@ pub(crate) fn unknown_entity(u: &Unknown, e: &Entity, owner: &str, paper: bool)
}
_ if in_group => continue,
5 | 67 => continue,
// After a subclass marker, 330 can be proxy data even without a source owner.
100 => owner_seen = true,
330 if !owner_seen => {
owner_seen = true;
continue;
@@ -925,7 +858,7 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
for (i, n) in recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).chain(appended).enumerate() {
numbers.entry(n).or_insert(500_i64.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)));
}
let renumber = |old| proxy_class_number(f, &numbers, old);
let renumber = |old: Option<i64>| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied());
// Entities of types CADCraft doesn't model, placed among the written ones.
let mut planned = plan_unknown(d, &recs, tags, &roles, modern);
for list in planned.values_mut() {
@@ -968,56 +901,43 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
let objs: HashMap<String, &ForeignObject> = f.objects.iter().map(|o| (o.handle.hex(), o)).collect();
let dict_entries: HashMap<String, &str> = f.dictionary.iter().map(|(k, h)| (h.hex(), k.as_str())).collect();
let nod = f.targets.iter().filter(|(_, k)| k.0 == "NOD" && k.1.is_empty()).map(|(h, _)| h.hex()).collect::<HashSet<_>>();
let hard = hard_owners(
f.objects.iter().map(|o| (o.handle.hex(), o.tags.iter().take_while(|t| t.code < 1000).filter(|t| t.code == 360).map(|t| t.value.trim()))),
);
// Each kept object's own owner (group 330), read one record at a time.
let own: HashMap<String, Option<String>> = f
let owners: HashMap<String, String> = f
.objects
.iter()
.map(|o| {
let t: Vec<Tag> = o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect();
(o.handle.hex(), head(&t).owner)
})
.filter_map(|o| Some((o.handle.hex(), head(&o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect::<Vec<_>>()).owner?)))
.collect();
let alive_memo = settle(&hard, |kept: &HashMap<String, bool>| {
// Keep established live paths available to descendants during this pass as well.
let mut alive_memo: HashMap<String, bool> = kept.iter().filter(|(_, live)| **live).map(|(h, &live)| (h.clone(), live)).collect();
let kept = |h: &str| kept.get(h) == Some(&true);
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none())
{
alive_memo.insert(o.handle.hex(), false);
}
for o in &f.objects {
let mut path: Vec<String> = Vec::new();
let mut cur = o.handle.hex();
let verdict = loop {
if let Some(&v) = alive_memo.get(&cur) {
break v;
}
if path.contains(&cur) || path.len() >= MAX_DEPTH {
break false;
}
path.push(cur.clone());
let Some(owner) = owner_of(own.get(&cur).cloned().flatten(), &cur, &hard, kept) else { break false };
if objs.contains_key(&owner) {
cur = owner;
continue;
}
if nod.contains(&owner) {
break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some();
}
if attached.contains(&cur) {
break true;
}
break unknown.contains(&owner) && written.contains(&owner);
};
for p in path {
alive_memo.insert(p, verdict);
let mut alive_memo: HashMap<String, bool> = HashMap::new();
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none()) {
alive_memo.insert(o.handle.hex(), false);
}
for o in &f.objects {
let mut path: Vec<String> = Vec::new();
let mut cur = o.handle.hex();
let verdict = loop {
if let Some(&v) = alive_memo.get(&cur) {
break v;
}
if path.contains(&cur) || path.len() >= MAX_DEPTH {
break false;
}
path.push(cur.clone());
let Some(owner) = owners.get(&cur).cloned() else { break false };
if objs.contains_key(&owner) {
cur = owner;
continue;
}
if nod.contains(&owner) {
break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some();
}
if attached.contains(&cur) {
break true;
}
break unknown.contains(&owner) && written.contains(&owner);
};
for p in path {
alive_memo.insert(p, verdict);
}
alive_memo
});
}
// New handles for the written kept objects, above every written handle and the seed.
let mut next = seed(tags).max(max_handle(tags).saturating_add(1));
let mut alive: HashMap<String, String> = HashMap::new();
@@ -1034,8 +954,11 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
if r.kind == "BLOCK" {
block = up(&val(b, 2).unwrap_or_default());
}
if source_class_record(f, r, &recs, tags, &class_names, &mut out) {
continue;
if r.kind == "ENDSEC" && r.sec == "CLASSES" {
for c in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) {
out.push(Tag::s(0, "CLASS"));
out.extend(c.iter().map(tag));
}
}
if r.kind == "ENDSEC" && r.sec == "OBJECTS" {
for o in f.objects.iter().filter(|o| map.alive.contains_key(&o.handle.hex())) {
@@ -1078,50 +1001,6 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
*tags = out;
}
/// Complete source tables keep positional identities even when names repeat.
fn proxy_class_number(f: &Foreign, numbers: &HashMap<String, i64>, old: Option<i64>) -> Option<i64> {
if f.complete_class_table {
class_index(old).filter(|&i| i < f.classes.len()).and(old)
} else {
class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied())
}
}
/// Source classes precede decoder-owned classes so proxy positions stay stable.
fn source_class_record(f: &Foreign, record: &Rec, recs: &[Rec], tags: &[Tag], class_names: &HashSet<String>, out: &mut Vec<Tag>) -> bool {
if record.sec != "CLASSES" {
return false;
}
if !f.complete_class_table {
if record.kind == "ENDSEC" {
for class in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) {
out.push(Tag::s(0, "CLASS"));
out.extend(class.iter().map(tag));
}
}
return false;
}
if record.kind == "CLASS" {
return true;
}
if record.kind != "ENDSEC" {
return false;
}
for class in &f.classes {
out.push(Tag::s(0, "CLASS"));
out.extend(class.iter().map(tag));
}
let source_names: HashSet<_> = f.classes.iter().filter_map(|c| c.iter().find(|t| t.code == 1)).map(|t| up(&t.value)).collect();
for class in recs.iter().filter(|r| r.sec == "CLASSES" && r.kind == "CLASS") {
let body = tags.get(class.start + 1..class.end).unwrap_or_default();
if val(body, 1).is_some_and(|n| !source_names.contains(&up(&n))) {
out.push(Tag::s(0, "CLASS"));
out.extend_from_slice(body);
}
}
false
}
/// Where the entities of types CADCraft doesn't model go: each before the next written entity of
/// its space or block (else at the end of it), as `(section, record)` lists by the index of the
/// record they precede. Owners come from the written block records.
@@ -1381,28 +1260,3 @@ fn seed(tags: &[Tag]) -> u64 {
fn max_handle(tags: &[Tag]) -> u64 {
tags.iter().filter(|t| t.code == 5 || t.code == 105).filter_map(|t| Handle::parse_hex(&t.str())).map(|h| h.0).max().unwrap_or(0)
}
#[cfg(test)]
mod hard_owner_tests {
use super::hard_owners;
#[test]
fn holders_are_unique_in_first_seen_order() {
let objects =
[("10".to_string(), ["00a", "A"].into_iter()), ("20".to_string(), ["A", "00A"].into_iter()), ("10".to_string(), ["A", "A"].into_iter())];
let owners = hard_owners(objects.into_iter());
assert_eq!(owners.get("A").unwrap(), &["10", "20"]);
assert_eq!(owners.len(), 1);
}
#[test]
fn many_distinct_holders_keep_file_order() {
let count = 10_000;
let owners = hard_owners((0..count).map(|i| (format!("{i:X}"), ["FFFF", "FFFF"].into_iter())));
let holders = owners.get("FFFF").unwrap();
assert_eq!(holders.len(), count);
for (i, holder) in holders.iter().enumerate() {
assert_eq!(holder, &format!("{i:X}"));
}
}
}
+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};
+7 -13
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,20 +124,14 @@ 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.
/// Ordinary DXF output retains source spellings.
/// Subclass markers the DWG bridge knows by another spelling (it matches them exactly):
/// AutoCAD writes a dynamic block lookup parameter's as `AcDbBlockLookUpParameter`, the bridge
/// reads `AcDbBlockLookupParameter`; otherwise the parameter loses its name and its lookup
/// action in the DWG, and AutoCAD's lookups stop working.
fn bridge_markers(tags: &mut [Tag]) {
for tag in tags.iter_mut().filter(|tag| tag.code == 0) {
for name in ["ACAD_PROXY_ENTITY", "ACAD_PROXY_OBJECT"] {
if tag.str().trim().eq_ignore_ascii_case(name) {
*tag = Tag::s(0, name);
break;
}
}
}
for t in tags.iter_mut().filter(|t| t.code == 100) {
if t.str().trim().eq_ignore_ascii_case("AcDbBlockLookupParameter") {
*t = Tag::s(100, "AcDbBlockLookupParameter");
+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() }
}
+5 -5
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};
@@ -114,10 +114,10 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
// Before 2007 the text is in the code page the header declares.
let code_page = dxf_write::code_page(d, version);
Ok(match (opts.binary, version.utf8()) {
(true, true) => 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
+17 -3
View File
@@ -50,6 +50,8 @@ const MAX_LAYER_NAME: usize = 1000;
/// Most link annotations in one file, and the longest link target (bytes).
const MAX_LINKS: usize = 10_000;
const MAX_URL: usize = 2048;
/// The most link annotations in one PDF, over all pages (a page holds at most [`MAX_LINKS`]).
const MAX_FILE_ANNOTATIONS: usize = 100_000;
/// Longest title and page label written (characters).
const MAX_TEXT: usize = 1000;
@@ -357,7 +359,10 @@ fn sheet_name(space: &Space) -> String {
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
/// layout name. The options apply to every sheet; layouts use their own page setups.
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), o.clone())).collect();
// Links are file-wide (`pdf_items` reads them from `o`), so a sheet's own options carry none:
// a copy of the list per sheet multiplied it by the sheet count.
let sheet = PdfOptions { links: Vec::new(), ..o.clone() };
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), sheet.clone())).collect();
pdf_items(d, &items, o)
}
@@ -453,8 +458,16 @@ fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as
let mut writer = Writer::new(settings, metadata);
let mut fonts = fonts::Fonts::new(!text_as_geometry);
let mut groups = Groups::default();
// Link annotations of the whole file: each page keeps its own copy of the targets, so the
// pages times the links must not multiply without a bound.
let mut annotations_left = MAX_FILE_ANNOTATIONS;
for (s, po) in items {
let page = sheet(drawn, s, po, &links)?;
// Once the budget is spent no link is built (scanned and cloned) for later pages.
let mut page = sheet(drawn, s, po, if annotations_left == 0 { &links[..0] } else { &links })?;
// Only targets `write_page` accepts count against the budget.
page.links.retain(|(_, url)| uri(url).is_some());
page.links.truncate(annotations_left);
annotations_left -= page.links.len();
let doc = writer.document_mut();
// Groups are added to the document, so they must exist before the page starts.
groups.add(doc, d, &page.layers);
@@ -625,7 +638,8 @@ pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Shee
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
// A target past MAX_URL is refused at the annotation anyway; it is left out before it is copied.
let mut out: Vec<(Handle, String)> = o.links.iter().filter(|(_, u)| u.len() <= MAX_URL).take(MAX_LINKS).cloned().collect();
if !o.hyperlinks {
return out;
}
+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()));
+11 -11
View File
@@ -1483,7 +1483,7 @@ fn ac1024_dwg_with_many_entities_opens() {
let n = 30_000;
let dwg = ac1024_dwg(n);
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let tags = craft_dxf::parse(&dxf).unwrap().len();
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();
@@ -1512,7 +1512,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
// Lines that aren't even group codes: the size is reported before anything is parsed.
let text = "not a group code\nx\n".repeat(60);
let e = 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)");
}
@@ -1608,7 +1608,7 @@ fn dimension_associativity_roundtrips() {
// Without CADCraft's xdata (a file from another writer), the standard DIMASSOC objects
// still give the extension-line links.
let tags = 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 {
@@ -1621,7 +1621,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);
@@ -1697,7 +1697,7 @@ fn polyline_vertex_dimensions_round_trip_by_segment() {
];
let hp = d.add(&Space::Model, c(), EntityKind::Dimension(on_pl.clone())).unwrap();
let text = write_dxf(&d);
let tags = 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()
@@ -1726,13 +1726,13 @@ fn polyline_vertex_dimensions_round_trip_by_segment() {
in_ref = t.str() == "AcDbOsnapPointRef";
}
if in_ref && matches!(t.code, 10 | 20) {
t = 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!(),
@@ -2041,7 +2041,7 @@ fn non_ascii_text_is_escaped_for_r2000_and_roundtrips() {
let d = intl_sample();
let text = write_dxf(&d);
assert!(text.is_ascii(), "an AC1015 DXF must not contain UTF-8 bytes");
let tags = 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"));
@@ -3511,7 +3511,7 @@ fn proxies_keep_their_class_when_classes_are_rewritten() {
/// classes there: a 2018 drawing's piping proxies came out as LARGE_RADIAL_DIMENSION and
/// SECTIONOBJECT, and the ODA File Converter refused the file ("AcDbProxyObject can't be cast to
/// AcDbBlockTableRecord"). A DWG before 2013 leaves them out (acadrust writes no class for them).
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn proxies_keep_their_class_in_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
@@ -3553,7 +3553,7 @@ fn proxies_keep_their_class_in_dwg() {
/// its CLASSES ([`proxy_file`]'s "Stray", 537): kept with that number it would name some other
/// class of the DWG, so the DWG leaves it out, as CADCraft's own save does, and keeps the proxies
/// whose classes it can name.
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
@@ -3602,7 +3602,7 @@ fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
/// numbers can't be mapped to the DWG's classes. Kept as they were, they named other classes
/// (the ODA refuses such a file), so the DWG leaves them out, as a pre-2013 DWG does, and the
/// rest of the drawing is written.
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
use acadrust::entities::{EntityType, ExtendedEntityData};
+1 -10
View File
@@ -2,15 +2,11 @@
//! the one written as `*Paper_Space` (the DWG header's paper space block), the others
//! `*Paper_SpaceN`. Saving used to make the first tab the active one again.
// The DWG bridge is unavailable on wasm and FreeBSD ARM64 (#2526); DXF still works.
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
use std::io::Cursor;
use cadcraft_doc::{Common, Drawing, EntityKind, Layout, Line, Space};
use cadcraft_geom::Vec3;
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
use cadcraft_io::Version;
use cadcraft_io::{SaveOptions, read, write_with};
use cadcraft_io::{SaveOptions, Version, read, write_with};
fn three_sheets() -> Drawing {
let mut d = Drawing::new_metric();
@@ -23,7 +19,6 @@ fn three_sheets() -> Drawing {
}
/// The layout the DWG marks active: the one whose block the header names as paper space.
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
fn dwg_active_layout(dwg: &[u8]) -> String {
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.to_vec())).read().unwrap();
let ps = doc.header.paper_space_block_handle;
@@ -36,13 +31,11 @@ fn dwg_active_layout(dwg: &[u8]) -> String {
.unwrap()
}
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
fn dwg(d: &Drawing) -> Vec<u8> {
write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap()
}
#[test]
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
fn the_active_layout_stays_active_through_dwg_saves() {
let mut d = three_sheets();
d.active_layout = Some("S2".into());
@@ -75,7 +68,6 @@ fn the_active_layout_survives_dxf_too() {
}
#[test]
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
fn without_an_active_layout_the_first_tab_is_active() {
let mut d = three_sheets();
d.active_layout = Some("gone".into());
@@ -83,7 +75,6 @@ fn without_an_active_layout_the_first_tab_is_active() {
}
#[test]
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
fn the_active_layout_name_is_matched_ignoring_case() {
let mut d = three_sheets();
d.active_layout = Some("s2".into());
+1 -1
View File
@@ -11,7 +11,7 @@
//! the same DWG version (an older DWG can't hold everything: a 2004 field has no value flags or
//! format), read back; CADCraft's DWG must hold the same objects. When the DWG is the DXF's own
//! version, that reference is also checked against the DXF as read.
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#![cfg(not(target_arch = "wasm32"))]
use std::fmt::Write as _;
use std::io::Cursor;
+1 -1
View File
@@ -4,7 +4,7 @@
//!
//! The DWG is written here with acadrust, giving the second 3D-polyline vertex the highest
//! handle: exactly what an editing program produces when it inserts a vertex.
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#![cfg(not(target_arch = "wasm32"))]
use acadrust::entities::{EntityType, Polyline3D, Vertex3DPolyline};
use acadrust::types::{DxfVersion, Handle, Vector3};
+1 -1
View File
@@ -3,7 +3,7 @@
//! it as empty ("No data was imported..."), though it reads the same drawing as DXF (and as an
//! AC1032 DWG) fine. The DWG bridge adds the dictionary AutoCAD writes, with its usual variables,
//! when the DXF has none.
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#![cfg(not(target_arch = "wasm32"))]
use std::io::Cursor;
-12
View File
@@ -1,8 +1,6 @@
//! TABLE heights inherit from the table style unless the file records a cell override.
#![cfg(not(target_arch = "wasm32"))]
// FreeBSD ARM64 has no native DWG bridge (#2526), but the DXF regression still applies.
use cadcraft_doc::{Common, Drawing, EntityKind, Space, Table, TableCell, TableStyle};
fn drawing() -> Drawing {
@@ -44,7 +42,6 @@ fn check(d: &Drawing) {
}
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn table_cell_heights_keep_inheritance_through_dwg() {
let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap();
// A style record can be present for metadata without overriding its text height.
@@ -71,13 +68,11 @@ fn table_cell_heights_keep_inheritance_through_dxf() {
// ---- cells with their own style record (review on #2588) ----
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
use acadrust::entities::table::{CellContent, CellStyle, CellStylePropertyFlags};
/// Write the drawing as a DWG, edit its first cell through acadrust (`edit`), write it again as a
/// DWG (or, with `as_dxf`, as a DXF by acadrust's writer) and read it with CADCraft. Returns the first cell's
/// text height override.
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::table::TableCell)) -> Option<f64> {
let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap();
let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap();
@@ -105,7 +100,6 @@ fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::ta
.unwrap()
}
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle {
let mut s = CellStyle::new();
s.text_height = height;
@@ -114,14 +108,12 @@ fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle {
}
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn a_cell_style_with_the_text_height_flag_keeps_its_height() {
let h = first_cell_height(false, |c| c.style = Some(styled(0.5, CellStylePropertyFlags::TEXT_HEIGHT)));
assert_eq!(h, Some(0.5));
}
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn content_with_format_override_bit_128_keeps_its_own_height() {
// acadrust writes the content's height as group 144 and the style's as 140; they differ here
// so the result shows which one CADCraft honoured.
@@ -139,7 +131,6 @@ fn content_with_format_override_bit_128_keeps_its_own_height() {
/// Only the first content's override stands for the cell: a later content's height must not
/// replace the first content's inherited one (review on #2588).
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn a_later_contents_height_does_not_replace_the_first_contents() {
let h = first_cell_height(false, |c| {
c.style = Some(styled(0.18, CellStylePropertyFlags::NONE));
@@ -156,7 +147,6 @@ fn a_later_contents_height_does_not_replace_the_first_contents() {
/// Bit 128 with an unusable content height copies nothing, so the writer's default style height
/// is still cleared (review on #2588).
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn bit_128_with_an_invalid_height_still_clears_the_default() {
for bad in [0.0, -1.0, f64::NAN] {
let h = first_cell_height(false, |c| {
@@ -172,14 +162,12 @@ fn bit_128_with_an_invalid_height_still_clears_the_default() {
}
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn a_cell_with_no_style_record_inherits_the_table_style() {
assert_eq!(first_cell_height(false, |c| c.style = None), None);
}
/// A style record with a height and neither override is not an override, through DWG.
#[test]
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
fn a_style_height_without_an_override_is_cleared() {
assert_eq!(first_cell_height(false, |c| c.style = Some(styled(0.18, CellStylePropertyFlags::NONE))), None);
}
-21
View File
@@ -1,21 +0,0 @@
//! The targets without the native DWG bridge still handle DXF and report an actionable error
//! for DWG instead of pretending that opening or saving succeeded.
#![cfg(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64")))]
use cadcraft_doc::Drawing;
use cadcraft_io::{read, write};
#[test]
fn no_dwg_bridge_preserves_dxf_and_reports_dwg_unavailable() {
let drawing = Drawing::new_metric();
let dxf = write(&drawing, "drawing.dxf").expect("DXF remains available");
assert!(read(&dxf, "drawing.dxf").is_ok());
let read_error = read(b"AC1032unavailable", "drawing.dwg").unwrap_err().to_string();
let write_error = write(&drawing, "drawing.dwg").unwrap_err().to_string();
for error in [read_error, write_error] {
assert!(error.contains("DWG"), "{error}");
assert!(error.contains("DXF"), "{error}");
}
}
-204
View File
@@ -1,204 +0,0 @@
//! An object of another application whose owner is null, but which another kept object holds by a
//! hard owner reference (360), is kept with it, as one application's proxy object held by another.
//! In DXF and in DWG, save after save.
use cadcraft_io::{read, read_dxf, write, write_dxf};
/// A named object dictionary entry `OTHER_APP` (a dictionary) with an XRECORD that holds a second
/// XRECORD by a 360 reference; the second one's owner is null. An object nothing keeps names the
/// second XRECORD by a 360 first.
fn source() -> String {
let groups: &[(i32, &str)] = &[
(0, "SECTION"),
(2, "ENTITIES"),
(0, "LINE"),
(5, "30"),
(8, "0"),
(10, "0"),
(20, "0"),
(11, "10"),
(21, "0"),
(0, "ENDSEC"),
(0, "SECTION"),
(2, "OBJECTS"),
(0, "DICTIONARY"),
(5, "C"),
(330, "0"),
(100, "AcDbDictionary"),
(3, "OTHER_APP"),
(350, "50"),
// An object nothing keeps (null owner, no holder) that also names the held record by a
// 360, before the holder does: it is dropped, and the held record stays with the holder.
(0, "XRECORD"),
(5, "60"),
(330, "0"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "stray"),
(360, "52"),
(0, "DICTIONARY"),
(5, "50"),
(330, "C"),
(100, "AcDbDictionary"),
(281, "1"),
(3, "HOLDER"),
(360, "51"),
(0, "XRECORD"),
(5, "51"),
(102, "{ACAD_REACTORS"),
(330, "50"),
(102, "}"),
(330, "50"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "holder"),
(360, "52"),
(0, "XRECORD"),
(5, "52"),
(102, "{ACAD_REACTORS"),
(330, "51"),
(102, "}"),
(330, "0"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "held"),
(0, "ENDSEC"),
(0, "EOF"),
];
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect()
}
fn check(text: &str, label: &str) {
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();
// Each record from its `0` group on, as (code, value) pairs.
let records: Vec<&[(&str, &str)]> = pairs
.iter()
.enumerate()
.filter(|(_, (c, _))| *c == "0")
.map(|(i, _)| {
let n = pairs[i + 1..].iter().position(|(c, _)| *c == "0").map_or(pairs.len(), |n| i + 1 + n);
&pairs[i..n]
})
.collect();
let with = |text: &str| records.iter().find(|r| r.contains(&("1", text))).copied().unwrap_or_else(|| panic!("{label}: no record {text}"));
let holder = with("holder");
let Some((_, held_handle)) = holder.iter().find(|(c, _)| *c == "360") else { panic!("{label}: the holder holds nothing: {holder:?}") };
let held = with("held");
assert!(held.contains(&("5", *held_handle)), "{label}: the holder's 360 names the held record: {holder:?} {held:?}");
assert!(!records.iter().any(|r| r.contains(&("1", "stray"))), "{label}: the object nothing keeps is dropped");
}
#[test]
fn an_object_held_by_a_kept_one_is_kept() {
let mut d = read_dxf(source().as_bytes()).expect("read");
for generation in 1..=2 {
let text = write_dxf(&d);
check(&text, &format!("DXF {generation}"));
d = read_dxf(text.as_bytes()).expect("reread");
}
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
for generation in 1..=2 {
let dwg = write(&d, "held.dwg").expect("DWG");
d = read(&dwg, "held.dwg").expect("DWG read");
check(&write_dxf(&d), &format!("DWG {generation}"));
}
}
/// Mutual hard owners must not erase the live path through a retained dictionary entry.
#[test]
fn cyclic_hard_owners_with_a_live_holder_stay_kept() {
let groups = [
(0, "SECTION"),
(2, "OBJECTS"),
(0, "DICTIONARY"),
(5, "C"),
(330, "0"),
(100, "AcDbDictionary"),
(3, "OTHER_APP"),
(350, "50"),
(0, "XRECORD"),
(5, "60"),
(330, "0"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "stray"),
(360, "54"),
(0, "XRECORD"),
(5, "52"),
(330, "0"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "first"),
(360, "53"),
(360, "54"),
(0, "XRECORD"),
(5, "53"),
(330, "0"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "second"),
(360, "52"),
(0, "XRECORD"),
(5, "54"),
(330, "0"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "child"),
(0, "DICTIONARY"),
(5, "50"),
(330, "C"),
(100, "AcDbDictionary"),
(281, "1"),
(3, "HOLDER"),
(360, "51"),
(0, "XRECORD"),
(5, "51"),
(330, "50"),
(100, "AcDbXrecord"),
(280, "1"),
(1, "holder"),
(360, "52"),
(360, "53"),
(0, "ENDSEC"),
(0, "EOF"),
];
let text = groups.iter().map(|(code, value)| format!("{code}\n{value}\n")).collect::<String>();
let mut drawing = read_dxf(text.as_bytes()).unwrap();
let mut names = vec!["copy.dxf", "again.dxf"];
if cfg!(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64")))) {
names.extend(["copy.dwg", "again.dwg"]);
}
for name in names {
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 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();
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}");
for object in [&first, &second] {
assert!(holder.iter().any(|t| t.code == 360 && t.str() == handle(object)), "{name}");
}
}
}
/// A real 330 owner takes precedence, even when a retained object also names the record by 360.
#[test]
fn a_real_owner_is_not_replaced_by_a_hard_owner() {
let text = source().replace("330\n0\n100\nAcDbXrecord\n280\n1\n1\nheld", "330\nDEAD\n100\nAcDbXrecord\n280\n1\n1\nheld");
assert!(text.contains("330\nDEAD"));
let drawing = read_dxf(text.as_bytes()).unwrap();
let output = write_dxf(&drawing);
assert!(output.lines().any(|line| line.trim() == "holder"));
assert!(!output.lines().any(|line| line.trim() == "held"));
}
+1 -1
View File
@@ -103,7 +103,7 @@ fn dxf_angular_block_text_keeps_the_decimal_separator() {
}
}
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_angular_block_text_keeps_the_decimal_separator() {
use std::io::Cursor;
+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)] {
-2
View File
@@ -2,8 +2,6 @@
//! by their own block (entity mode 0 and its handle). Written as "paper space" (mode 1), AutoCAD
//! put them on the first layout and left theirs empty.
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
use std::io::Cursor;
use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Layout, Line, Space};
+6 -6
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
-2
View File
@@ -87,7 +87,6 @@ fn a_saved_drawing_keeps_the_dynamic_block_as_read() {
}
/// The Door fixture with a scale action on the Width parameter: graph node 6, object 68.
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
fn with_scale_action() -> String {
let node = "91 6\n93 32\n95 7\n360 68\n92 -1\n92 -1\n92 -1\n92 -1\n";
let action = "0 BLOCKSCALEACTION\n5 68\n330 61\n100 AcDbEvalExpr\n90 7\n98 33\n99 232\n\
@@ -106,7 +105,6 @@ fn with_scale_action() -> String {
/// 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 (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]
fn a_scale_action_survives_a_dwg_save() {
use acadrust::objects::{DynamicBlockData, DynamicBlockObject, ObjectType};
+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()) };
+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();
+2 -2
View File
@@ -74,7 +74,7 @@ fn dxf_mtext_caret_spaces_round_trip() -> TestResult {
Ok(())
}
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
let (original, expected) = drawing()?;
@@ -103,7 +103,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
Ok(())
}
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult {
let (d, _) = drawing()?;
+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,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);
@@ -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();
+1 -1
View File
@@ -4,7 +4,7 @@
mod cases;
mod collections;
mod compare;
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
#[cfg(not(target_arch = "wasm32"))]
mod dwg;
mod dxf;
mod handle_limits;
+3 -3
View File
@@ -4,9 +4,9 @@
//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it.
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown};
use cadcraft_dxf::{records, sections};
use cadcraft_geom::Vec3;
use cadcraft_io::{SaveOptions, Version, read, write_with};
use craft_dxf::{records, sections};
fn drawing() -> Drawing {
let mut d = super::cases::drawing();
@@ -55,7 +55,7 @@ fn preserved(original: &Drawing, back: &Drawing, context: &str) {
}
fn dxf_owner(bytes: &[u8]) {
let tags = 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,7 +79,7 @@ pub(super) fn check(path: &str, version: Version, binary: bool) {
Err(error) => panic!("{context}: {error}"),
};
if path.ends_with(".dxf") {
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
dxf_owner(&bytes);
} else {
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
+3 -3
View File
@@ -8,8 +8,8 @@
use std::collections::HashMap;
use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_dxf::{Tag, records, sections};
use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with};
use craft_dxf::{Tag, records, sections};
/// DXF text from lines of `code value` (the value may be empty).
fn dxf(src: &str) -> String {
@@ -565,7 +565,7 @@ struct Out {
impl Out {
fn new(text: &str) -> Out {
let tags = 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")
@@ -76,8 +76,8 @@ fn multileader_styles_and_current_style_round_trip() {
d.mleader_styles.push(callout);
d.header.set_str("CMLEADERSTYLE", "Callout");
let text = write_dxf(&d);
let tags = craft_dxf::parse(text.as_bytes()).expect("parse written DXF");
let header = craft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
let header = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
assert!(header.tags.iter().all(|t| t.code != 9 || t.str() != "$CMLEADERSTYLE"));
let r = read_dxf(text.as_bytes()).expect("reopen");
assert_eq!(r.mleader_styles, d.mleader_styles);
+1 -1
View File
@@ -114,7 +114,7 @@ fn entget_shows_plain_carets_and_entmake_takes_them() {
// entmod of entget changes nothing; entmake takes a plain string.
let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap();
assert_eq!(strings(&back), ["x^ y 10^2"]);
let made = [craft_dxf::Tag::s(1, "p^ q")];
let made = [cadcraft_dxf::Tag::s(1, "p^ q")];
assert_eq!(strings(&cadcraft_io::entity_from_groups("TEXT", &made).unwrap().1), ["p^ q"]);
// Every kind, and a DIMENSION's style name (also group 3) as it is.
let d = drawing();

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