mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 20:40:30 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c979db0807 | ||
|
|
a363185aa6 | ||
|
|
72eab5bb48 | ||
|
|
19bb8c4a84 |
@@ -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
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() };
|
||||
assert_eq!(is_binary(&bytes), binary, "{context}");
|
||||
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
|
||||
assert_eq!(back.len(), original.len(), "{context}: tag count");
|
||||
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
|
||||
assert_eq!(actual, expected, "{context}: tag[{index}]");
|
||||
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
|
||||
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
|
||||
}
|
||||
}
|
||||
assert_eq!(back, current, "{context}: no additional changes on resave");
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_width_boundaries() {
|
||||
// Explicit reference ranges, not obtained from the codec's kind_of().
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, min, max) in widths {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
|
||||
check(&format!("integer/{min}..{max}/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_reals_keep_all_bits() {
|
||||
let values = [
|
||||
0.0,
|
||||
-0.0,
|
||||
f64::from_bits(1),
|
||||
-f64::from_bits(1),
|
||||
f64::MIN_POSITIVE,
|
||||
f64::MIN_POSITIVE.next_down(),
|
||||
0.1,
|
||||
-3.25,
|
||||
1.0_f64.next_down(),
|
||||
1.0,
|
||||
1.0_f64.next_up(),
|
||||
1e-100,
|
||||
-1e100,
|
||||
f64::MAX,
|
||||
f64::MIN,
|
||||
];
|
||||
for value in values {
|
||||
let tags: Vec<_> =
|
||||
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
|
||||
check(&format!("real/{:016X}", value.to_bits()), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans() {
|
||||
for value in [false, true] {
|
||||
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
|
||||
check(&format!("bool/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
|
||||
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
|
||||
let values = [
|
||||
("empty", ""),
|
||||
("spaces", " leading and trailing "),
|
||||
("tabs", "\tindented\t"),
|
||||
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
|
||||
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
|
||||
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
|
||||
];
|
||||
for (case, value) in values {
|
||||
let tags: Vec<_> =
|
||||
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|code| Tag::s(code, value))
|
||||
.collect();
|
||||
check(case, &tags);
|
||||
}
|
||||
for length in [1, 254, 255, 256, 1024] {
|
||||
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_chunks_keep_bytes_and_order() {
|
||||
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
|
||||
// byte can represent more, but that is reader tolerance, not writer conformance.
|
||||
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
|
||||
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
|
||||
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
|
||||
check(&format!("hex/bytes={length}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_repeated_codes_keep_order() {
|
||||
check(
|
||||
"mixed/repeated-codes",
|
||||
&[
|
||||
Tag::s(0, "TEST"),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::s(1, "first"),
|
||||
Tag::f(10, -2.5),
|
||||
Tag::i(70, -7),
|
||||
Tag::s(1, "second"),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FF80".into())),
|
||||
Tag::i(160, i64::MAX),
|
||||
Tag::s(1, ""),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
cadcraft-constraints = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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(¶s, &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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,8 +13,8 @@ use std::collections::HashMap;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
|
||||
use cadcraft_dxf::{Tag, Version};
|
||||
use cadcraft_render::Arrowhead;
|
||||
use craft_dxf::{Tag, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle, Image};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use craft_dxf::{Section, Tag};
|
||||
|
||||
/// Most clip boundary vertices kept per image (hostile files).
|
||||
const MAX_CLIP: usize = 100_000;
|
||||
|
||||
@@ -13,8 +13,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, Handle, Insert, MLeader, MLeaderStyle, MText};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use craft_dxf::Tag;
|
||||
|
||||
/// Most leader roots (landings) read per multileader.
|
||||
const MAX_ROOTS: usize = 256;
|
||||
|
||||
@@ -12,8 +12,8 @@ use cadcraft_doc::{
|
||||
Drawing, Entity, EntityKind, EntityStore, MAX_MLINE_CUTS, MAX_MLINE_ELEMENTS, MAX_MLINE_VERTICES, MLine, MLineCaps, MLineCut, MLineElement,
|
||||
MLineJust, MLineStyle, MLineVertex, STANDARD_MLINE_STYLE,
|
||||
};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use craft_dxf::{Section, Tag};
|
||||
|
||||
/// Most groups 41 kept per element and vertex (start, then break pairs).
|
||||
const MAX_PARAMS: usize = 2 * MAX_MLINE_CUTS + 2;
|
||||
|
||||
+73
-219
@@ -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(¤t) {
|
||||
break known;
|
||||
}
|
||||
if path.contains(¤t) || 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(¤t) {
|
||||
break known;
|
||||
}
|
||||
if path.contains(¤t) || 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(¤t) {
|
||||
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(¤t) {
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! kept as unknown objects.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HatchLoop, LwPolyline, MAX_REGION_LOOPS, Region};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use craft_dxf::Tag;
|
||||
|
||||
use crate::dxf_ext::{APP, xdata, xdata_list};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{CellBorder, CellStyle, DataLink, Lineweight, MAX_CELL_STYLES, MAX_TABLE_PARTS, Table, TableBreak, TableStyle};
|
||||
use craft_dxf::Tag;
|
||||
use cadcraft_dxf::Tag;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dxf_ext::{APP, xdata};
|
||||
|
||||
@@ -11,8 +11,8 @@ use cadcraft_doc::{
|
||||
HVal, Handle, Hatch, Insert, Layout, Leader, Line, Lineweight, LwPolyline, MLeader, MLeaderStyle, MText, PageSetup, Point, Solid, Space, Table,
|
||||
TableStyle, Text, Transparency, VAlign, VportConfig, VportTile,
|
||||
};
|
||||
use cadcraft_dxf::{Tag, Version};
|
||||
use cadcraft_geom::{Bounds2, Vec2, Vec3};
|
||||
use craft_dxf::{Tag, Version};
|
||||
|
||||
use crate::dxf_ext::{self, DimVal, K};
|
||||
|
||||
@@ -111,7 +111,7 @@ impl W {
|
||||
/// Write a drawing as ASCII DXF.
|
||||
/// The drawing as R2000 ASCII DXF.
|
||||
pub fn write(d: &Drawing) -> String {
|
||||
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");
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = §ions.iter().find(|s| s.name == "HEADER").unwrap().tags;
|
||||
for (name, unit) in [("$SNAPUNIT", snap), ("$GRIDUNIT", grid)] {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()) };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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(§ions(&tags).into_iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
let polys: Vec<_> = ents.iter().filter(|(k, _)| k == "LWPOLYLINE").collect();
|
||||
assert_eq!(polys.len(), 5);
|
||||
|
||||
@@ -47,7 +47,7 @@ fn edit_primary(drawing: &mut Drawing, edit: impl Fn(&mut Attrib)) {
|
||||
fn reopen(drawing: &Drawing, version: Version, binary: bool, context: &str) -> Drawing {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let bytes = cadcraft_io::write_with(drawing, "attributes.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(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);
|
||||
|
||||
@@ -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(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::<Vec<_>>(), ["LINE", "CIRCLE", "TEXT"]);
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Circle, Common, Drawing, Entity, EntityKind, HAlign, Handle, Line, Space, Text, VAlign};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
use super::compare;
|
||||
|
||||
@@ -88,7 +88,7 @@ fn writes_standard_entity_groups_not_just_self_readable_data() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&model(), "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.len(), expected.len());
|
||||
@@ -161,7 +161,7 @@ fn writes_large_handles_as_exact_hex_groups() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.len(), LARGE_HANDLES.len());
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
use super::super::{cases, compare};
|
||||
use cadcraft_doc::{Arc, Common, Ellipse, EntityKind, LwPolyline, MText, Point, RayLine};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
struct Fixture {
|
||||
wire_type: &'static str,
|
||||
@@ -121,7 +121,7 @@ fn writes_extended_entities_with_independent_groups() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&source.drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let records = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(records.len(), 1);
|
||||
|
||||
@@ -39,7 +39,7 @@ fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, versio
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = write_with(¤t, "memory.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(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(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags);
|
||||
let matching: Vec<_> = tables.into_iter().filter(|(kind, groups)| kind == table && groups.contains(&Tag::s(2, "Flags"))).collect();
|
||||
|
||||
@@ -4,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;
|
||||
|
||||
@@ -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(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags);
|
||||
let (_, model) = tables
|
||||
@@ -79,7 +79,7 @@ pub(super) fn check(path: &str, version: Version, binary: bool) {
|
||||
Err(error) => panic!("{context}: {error}"),
|
||||
};
|
||||
if path.ends_with(".dxf") {
|
||||
assert_eq!(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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user