mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-14 01:47:26 -06:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8a1a74c5 | ||
|
|
1878076750 | ||
|
|
ca87680fb0 | ||
|
|
c736e488ba | ||
|
|
2ef7c3bed1 | ||
|
|
5a6aa9b65f | ||
|
|
b460a3b8d9 | ||
|
|
cb96c182ef | ||
|
|
e1d379ad9f | ||
|
|
dc7f382cec | ||
|
|
e5021f0175 | ||
|
|
2d25308cfc | ||
|
|
20b4472f0b | ||
|
|
ecb362aa55 | ||
|
|
dea9f2d048 | ||
|
|
d6d16bd474 | ||
|
|
2410a1fd07 | ||
|
|
f132d81609 | ||
|
|
a1574780c2 | ||
|
|
ea2637ae73 | ||
|
|
7e780548be | ||
|
|
6d744887f4 | ||
|
|
2c25c2d700 | ||
|
|
95fdf8d11a | ||
|
|
650ae383f2 |
@@ -0,0 +1,34 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ast-grep.yml': []
|
||||
'.github/workflows/ci.yml': []
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -27,7 +27,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -27,7 +27,11 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "8 1 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -30,7 +34,11 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -72,6 +80,7 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
Generated
+31
-28
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -645,7 +645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -661,14 +661,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -678,7 +678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -689,34 +689,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"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"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"craft-dxf",
|
||||
"libc",
|
||||
"log",
|
||||
"rstar",
|
||||
@@ -727,10 +718,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"craft-dxf",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
@@ -740,23 +731,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-dxf",
|
||||
"craft-pdf",
|
||||
"image",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -769,13 +760,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
@@ -783,7 +775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -797,7 +789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -824,7 +816,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
@@ -1151,6 +1143,16 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=006e4df225fca57e95ead0fb1f91920900798a99#006e4df225fca57e95ead0fb1f91920900798a99"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1213,7 +1215,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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1223,6 +1225,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5992,7 +5995,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,7 +13,8 @@ repository = "https://github.com/storytold/cadcraft"
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
cadcraft-geom = { path = "crates/geom" }
|
||||
cadcraft-dxf = { path = "crates/dxf" }
|
||||
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
|
||||
cadcraft-dwg = { path = "crates/dwg" }
|
||||
cadcraft-color = { path = "crates/color" }
|
||||
cadcraft-doc = { path = "crates/doc" }
|
||||
@@ -71,7 +72,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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,10 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
let mut extra = Vec::new();
|
||||
let mut on = Vec::new();
|
||||
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
|
||||
// Nothing is added once the candidates are full, so the points left need no search.
|
||||
if extra.len() + on.len() + out.len() >= MAX_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
|
||||
continue;
|
||||
}
|
||||
@@ -267,20 +271,22 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
|
||||
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && placed.contains(&r.handle)) {
|
||||
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
|
||||
// Full: nothing more can be added, so the pairs left need no geometry or duplicate search.
|
||||
if out.len() + extra.len() + on.len() >= MAX_CANDIDATES {
|
||||
break 'per;
|
||||
}
|
||||
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
|
||||
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
|
||||
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
|
||||
let ang = ang_between(*b1 - *a1, *b2 - *a2);
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
// The duplicate search scans every candidate, so it comes after the cheap tests.
|
||||
if tee
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
+31
-1
@@ -204,6 +204,23 @@ impl ArrayParams {
|
||||
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
|
||||
}
|
||||
|
||||
/// [`block_for`](Self::block_for) for many items: the replacements are indexed once (the last
|
||||
/// entry for a place wins, as there), so each lookup is O(1) instead of a scan of `replaced`.
|
||||
pub fn block_lookup<'a>(&'a self) -> impl Fn(u32, u32, u32) -> &'a str + 'a {
|
||||
// Only places `items()` generates are indexed, so stray out-of-range records cost nothing.
|
||||
let (cols, rows) = match &self.kind {
|
||||
ArrayKind::Rect(r) => (r.cols, r.rows),
|
||||
ArrayKind::Polar(p) => (p.items, p.rows),
|
||||
};
|
||||
let index: std::collections::HashMap<(u32, u32, u32), &str> = self
|
||||
.replaced
|
||||
.iter()
|
||||
.filter(|r| r.i < cols && r.j < rows && r.level < self.levels)
|
||||
.map(|r| ((r.i, r.j, r.level), r.block.as_str()))
|
||||
.collect();
|
||||
move |i, j, level| index.get(&(i, j, level)).copied().unwrap_or(self.source.as_str())
|
||||
}
|
||||
|
||||
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
|
||||
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
|
||||
/// parameters are not [valid](Self::is_valid).
|
||||
@@ -217,9 +234,10 @@ impl ArrayParams {
|
||||
..Common::default()
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let block_for = self.block_lookup();
|
||||
for it in items {
|
||||
let ins = Insert {
|
||||
block: self.block_for(it.i, it.j, it.level).to_string(),
|
||||
block: block_for(it.i, it.j, it.level).to_string(),
|
||||
insert: it.insert,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: it.rotation,
|
||||
@@ -301,6 +319,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_lookup_agrees_with_block_for_and_the_last_replacement_wins() {
|
||||
let mut p = rect(3, 2);
|
||||
let r = |i, j, level, b: &str| ReplacedItem { i, j, level, block: b.into() };
|
||||
p.replaced = vec![r(1, 0, 0, "A"), r(1, 0, 0, "B"), r(2, 1, 1, "C")];
|
||||
let look = p.block_lookup();
|
||||
for (i, j, level) in [(0, 0, 0), (1, 0, 0), (2, 1, 1), (2, 1, 0)] {
|
||||
assert_eq!(look(i, j, level), p.block_for(i, j, level));
|
||||
}
|
||||
assert_eq!((look(1, 0, 0), look(0, 0, 0)), ("B", "*U1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_items_go_column_row_level() {
|
||||
let items = rect(3, 2).items().unwrap_or_default();
|
||||
|
||||
@@ -674,6 +674,10 @@ fn apply(
|
||||
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
|
||||
/// outline again (it may have been stretched).
|
||||
fn refill_hatches(b: &mut Block) {
|
||||
// Each loop gets its own copy of the outline, so many small loops on one big outline multiply it;
|
||||
// past this many vertices in all, the remaining loops keep the outline they have.
|
||||
const MAX_REFILLED_VERTICES: usize = 4_000_000;
|
||||
let mut budget = MAX_REFILLED_VERTICES;
|
||||
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
|
||||
.entities
|
||||
.iter()
|
||||
@@ -689,7 +693,9 @@ fn refill_hatches(b: &mut Block) {
|
||||
for l in &mut hh.loops {
|
||||
if let [s] = l.sources.as_slice()
|
||||
&& let Some(vs) = outlines.get(s)
|
||||
&& let Some(left) = budget.checked_sub(vs.len())
|
||||
{
|
||||
budget = left;
|
||||
l.vertices.clone_from(vs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,16 +486,24 @@ impl DimStyle {
|
||||
|
||||
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
|
||||
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
|
||||
/// had unusable values.
|
||||
/// had unusable values. When several spellings name one field, the last one wins and the
|
||||
/// superseded ones are not validated.
|
||||
pub fn apply_fields(&mut self, src: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
|
||||
use serde_json::Value;
|
||||
let mut rejected = Vec::new();
|
||||
let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() };
|
||||
// Spellings of one field (`DIMPOST`, `dimpost`, `textPrefix`…): only the last is applied, so
|
||||
// the style is checked once per field, not once per spelling (each check copies its strings).
|
||||
let last_spelling: std::collections::HashMap<&str, &String> =
|
||||
src.keys().filter_map(|k| DimStyle::field_name(k).filter(|f| *f != "name").map(|f| (f, k))).collect();
|
||||
for (k, v) in src {
|
||||
let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
};
|
||||
if last_spelling.get(field) != Some(&k) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj) = cur.as_object_mut() else { break };
|
||||
let Some(old) = obj.get(field).cloned() else {
|
||||
rejected.push(k.clone());
|
||||
|
||||
@@ -714,3 +714,14 @@ fn space_names_its_tab() {
|
||||
assert_eq!(p.layout(), Some("Sheet A"));
|
||||
assert_eq!(p.tab_name(), "Sheet A");
|
||||
}
|
||||
|
||||
/// Several spellings of one DIM field apply once, the last in the map's order, and none is rejected.
|
||||
#[test]
|
||||
fn dim_style_spellings_of_one_field_apply_once() {
|
||||
let mut st = DimStyle::default();
|
||||
let m = serde_json::json!({ "textHeight": 2.5, "TEXTHEIGHT": 3.5, "DIMTXT": 4.5 });
|
||||
let m = m.as_object().unwrap();
|
||||
let want = m.values().next_back().and_then(serde_json::Value::as_f64).unwrap();
|
||||
assert!(st.apply_fields(m).is_empty());
|
||||
assert_eq!(st.text_height, want);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "cadcraft-dxf"
|
||||
description = "Standalone DXF tag reader/writer (ASCII and binary, pre-2007 code pages), from the published DXF reference"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
@@ -1,398 +0,0 @@
|
||||
//! Text decoding for DXF files.
|
||||
//!
|
||||
//! R2007 (AC1021) and later DXF files are UTF-8. Older ones store text in the code page named by
|
||||
//! the `$DWGCODEPAGE` header variable (`ANSI_1252`, `ANSI_932`, `DOS850`, …); characters outside
|
||||
//! it are written as `\U+XXXX` (any character) or `\M+nXXXX` (a double-byte character of an East
|
||||
//! Asian code page) escapes, which [`crate::parse`] decodes after the bytes.
|
||||
//!
|
||||
//! The Windows, ISO and East Asian code pages are decoded with `encoding_rs`; DOS 437, 850 and
|
||||
//! 852 with the tables below (the upper halves of the IBM PC code pages; the lower halves are
|
||||
//! ASCII). Decoding never fails: a byte sequence that is invalid in the code page (a truncated
|
||||
//! double-byte character, an unassigned byte) becomes U+FFFD and the bytes after it decode
|
||||
//! normally, so line ends and the group structure survive.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
|
||||
/// How the bytes of a code page become text.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Decoder {
|
||||
Utf8,
|
||||
Encoding(&'static Encoding),
|
||||
/// Single-byte DOS code page: ASCII, then these 128 characters for bytes 0x80–0xFF.
|
||||
Table(&'static [char; 128]),
|
||||
}
|
||||
|
||||
/// The decoder for a `$DWGCODEPAGE` value (case-insensitive), or `None` when CADCraft doesn't
|
||||
/// know the code page.
|
||||
pub(crate) fn decoder(code_page: &str) -> Option<Decoder> {
|
||||
use encoding_rs as e;
|
||||
let name = code_page.trim().to_ascii_uppercase().replace(['-', ' '], "_");
|
||||
let enc = match name.as_str() {
|
||||
"UTF8" | "UTF_8" | "UNICODE" => return Some(Decoder::Utf8),
|
||||
"ASCII" | "ANSI_1252" | "ISO8859_1" | "ISO_8859_1" => e::WINDOWS_1252,
|
||||
"ANSI_874" => e::WINDOWS_874,
|
||||
"ANSI_932" | "DOS932" | "SHIFT_JIS" => e::SHIFT_JIS,
|
||||
"ANSI_936" | "GB2312" | "GBK" => e::GBK,
|
||||
"ANSI_949" | "KSC5601" | "KOREAN" => e::EUC_KR,
|
||||
"ANSI_950" | "BIG5" => e::BIG5,
|
||||
"ANSI_1250" => e::WINDOWS_1250,
|
||||
"ANSI_1251" => e::WINDOWS_1251,
|
||||
"ANSI_1253" => e::WINDOWS_1253,
|
||||
"ANSI_1254" | "ISO8859_9" | "ISO_8859_9" => e::WINDOWS_1254,
|
||||
"ANSI_1255" => e::WINDOWS_1255,
|
||||
"ANSI_1256" => e::WINDOWS_1256,
|
||||
"ANSI_1257" => e::WINDOWS_1257,
|
||||
"ANSI_1258" => e::WINDOWS_1258,
|
||||
"ISO8859_2" | "ISO_8859_2" => e::ISO_8859_2,
|
||||
"ISO8859_3" | "ISO_8859_3" => e::ISO_8859_3,
|
||||
"ISO8859_4" | "ISO_8859_4" => e::ISO_8859_4,
|
||||
"ISO8859_5" | "ISO_8859_5" => e::ISO_8859_5,
|
||||
"ISO8859_6" | "ISO_8859_6" => e::ISO_8859_6,
|
||||
"ISO8859_7" | "ISO_8859_7" => e::ISO_8859_7,
|
||||
"ISO8859_8" | "ISO_8859_8" => e::ISO_8859_8,
|
||||
"DOS866" => e::IBM866,
|
||||
"MACINTOSH" | "MAC_ROMAN" => e::MACINTOSH,
|
||||
"DOS437" => return Some(Decoder::Table(&DOS437)),
|
||||
"DOS850" => return Some(Decoder::Table(&DOS850)),
|
||||
"DOS852" => return Some(Decoder::Table(&DOS852)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Decoder::Encoding(enc))
|
||||
}
|
||||
|
||||
/// Decode `bytes` from the code page `code_page` names (a `$DWGCODEPAGE` value such as
|
||||
/// `ANSI_1251`), or `None` when CADCraft doesn't know that code page. Invalid sequences become
|
||||
/// U+FFFD; escapes (`\U+XXXX`, `\M+nXXXX`) are left as they are.
|
||||
pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
|
||||
decoder(code_page).map(|d| decode(bytes, d).into_owned())
|
||||
}
|
||||
|
||||
/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes:
|
||||
/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as
|
||||
/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007
|
||||
/// reader expects.
|
||||
pub(crate) fn encoder(code_page: &str) -> Option<Decoder> {
|
||||
if code_page.trim().eq_ignore_ascii_case("ASCII") {
|
||||
return None;
|
||||
}
|
||||
decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8))
|
||||
}
|
||||
|
||||
/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when
|
||||
/// the code page has it; otherwise append nothing and return false (the caller escapes it).
|
||||
pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec<u8>) -> bool {
|
||||
let mut buf = [0; 4];
|
||||
match d {
|
||||
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
|
||||
Decoder::Utf8 => return false,
|
||||
Decoder::Encoding(e) => {
|
||||
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
|
||||
// A code page byte that is ASCII would read back as another character, and a NUL,
|
||||
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
|
||||
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
|
||||
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
|
||||
return false;
|
||||
}
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
Decoder::Table(t) => {
|
||||
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
|
||||
out.push(0x80 | i);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
|
||||
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match d {
|
||||
Decoder::Utf8 => match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => encoding_rs::WINDOWS_1252.decode_without_bom_handling(bytes).0,
|
||||
},
|
||||
Decoder::Encoding(e) => e.decode_without_bom_handling(bytes).0,
|
||||
Decoder::Table(t) => match std::str::from_utf8(bytes) {
|
||||
Ok(s) if s.is_ascii() => Cow::Borrowed(s),
|
||||
_ => Cow::Owned(
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| if b.is_ascii() { char::from(b) } else { t.get(usize::from(b & 0x7F)).copied().unwrap_or('\u{FFFD}') })
|
||||
.collect(),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The `$DWGCODEPAGE` value of an ASCII DXF file: the line two lines after a `$DWGCODEPAGE`
|
||||
/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops
|
||||
/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded.
|
||||
fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty());
|
||||
while let Some(line) = lines.next() {
|
||||
if line == b"ENDSEC" {
|
||||
return None;
|
||||
}
|
||||
if line.eq_ignore_ascii_case(b"$DWGCODEPAGE") {
|
||||
return match (lines.next(), lines.next()) {
|
||||
(Some(b"3"), Some(value)) => Some(value),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5).
|
||||
/// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for
|
||||
/// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]).
|
||||
fn is_east_asian(self) -> bool {
|
||||
use encoding_rs as e;
|
||||
matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII
|
||||
/// text, and older files from writers that ignored the code page); anything else comes from the
|
||||
/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use
|
||||
/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read
|
||||
/// from the code page (see [`BinaryText::string`]).
|
||||
pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => decode(bytes, d),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for
|
||||
/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that
|
||||
/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual
|
||||
/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is
|
||||
/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from
|
||||
/// that code page, because a short double-byte string (two half-width katakana, many kanji
|
||||
/// pairs) can be valid UTF-8 by accident and must not be misread line by line.
|
||||
pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let d = match ascii_code_page(bytes) {
|
||||
Some(name) => {
|
||||
let name = String::from_utf8_lossy(name);
|
||||
decoder(&name).unwrap_or_else(|| {
|
||||
log::warn!("DXF: unknown code page `{name}`; reading the text as ANSI_1252");
|
||||
Decoder::Utf8
|
||||
})
|
||||
}
|
||||
None => Decoder::Utf8,
|
||||
};
|
||||
if d.is_east_asian() {
|
||||
return decode(bytes, d);
|
||||
}
|
||||
// Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside
|
||||
// a multi-byte character of the supported code pages) and decode each line on its own.
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') {
|
||||
out.push_str(&decode_text(line, d));
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER`
|
||||
/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come
|
||||
/// first in the HEADER section and are ASCII, so every later string is decoded right.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BinaryText {
|
||||
/// The header variable whose value comes next (after a group 9).
|
||||
var: Option<HeaderVar>,
|
||||
utf8: bool,
|
||||
code_page: Option<Decoder>,
|
||||
/// Decode every string of an East Asian code page file from the code page, never as UTF-8
|
||||
/// (the second pass of [`BinaryText::needs_code_page_pass`]).
|
||||
code_page_only: bool,
|
||||
/// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8.
|
||||
ea_utf8: bool,
|
||||
ea_other: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum HeaderVar {
|
||||
AcadVer,
|
||||
CodePage,
|
||||
}
|
||||
|
||||
impl BinaryText {
|
||||
/// The state for the second pass over a file whose strings are a mix of valid UTF-8 and
|
||||
/// not: every string of an East Asian code page is decoded from that code page.
|
||||
pub(crate) fn code_page_pass() -> Self {
|
||||
Self { code_page_only: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are
|
||||
/// not all valid UTF-8 although some are: those can be short double-byte strings that look
|
||||
/// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A
|
||||
/// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.)
|
||||
pub(crate) fn needs_code_page_pass(&self) -> bool {
|
||||
!self.code_page_only && self.ea_utf8 && self.ea_other
|
||||
}
|
||||
|
||||
/// Decode one string value of group `code` and update the state from it. Strings of R2007+
|
||||
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a
|
||||
/// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so
|
||||
/// they are read from the code page even when their bytes are valid UTF-8 by accident —
|
||||
/// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken
|
||||
/// as UTF-8 (see [`Self::needs_code_page_pass`]).
|
||||
pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String {
|
||||
let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) };
|
||||
let s = if d.is_east_asian() && !self.code_page_only {
|
||||
if !raw.is_ascii() {
|
||||
if std::str::from_utf8(raw).is_ok() {
|
||||
self.ea_utf8 = true;
|
||||
} else {
|
||||
self.ea_other = true;
|
||||
}
|
||||
}
|
||||
decode_text(raw, d).into_owned()
|
||||
} else {
|
||||
decode(raw, d).into_owned()
|
||||
};
|
||||
match (code, self.var.take()) {
|
||||
(9, _) => {
|
||||
self.var = match s.trim() {
|
||||
"$ACADVER" => Some(HeaderVar::AcadVer),
|
||||
"$DWGCODEPAGE" => Some(HeaderVar::CodePage),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(1, Some(HeaderVar::AcadVer)) => self.utf8 = is_utf8_version(&s),
|
||||
(3, Some(HeaderVar::CodePage)) => {
|
||||
self.code_page = decoder(&s);
|
||||
if self.code_page.is_none() {
|
||||
log::warn!("DXF: unknown code page `{}`; reading the text as ANSI_1252", s.trim());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True for an `$ACADVER` of R2007 (AC1021) or later, whose text is UTF-8.
|
||||
fn is_utf8_version(acadver: &str) -> bool {
|
||||
acadver.trim().strip_prefix("AC").and_then(|n| n.parse::<u32>().ok()).is_some_and(|n| n >= 1021)
|
||||
}
|
||||
|
||||
/// The double-byte code page of a `\M+n` escape: 1 Japanese (Shift-JIS), 2 Traditional Chinese
|
||||
/// (Big5), 3 Korean (Wansung), 5 Simplified Chinese (GB 2312). 4 (Korean Johab) is not
|
||||
/// supported, so its escapes stay literal.
|
||||
pub(crate) fn mif_encoding(n: char) -> Option<&'static Encoding> {
|
||||
match n {
|
||||
'1' => Some(encoding_rs::SHIFT_JIS),
|
||||
'2' => Some(encoding_rs::BIG5),
|
||||
'3' => Some(encoding_rs::EUC_KR),
|
||||
'5' => Some(encoding_rs::GBK),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Generated from the IBM PC code page definitions (bytes 0x80–0xFF in order).
|
||||
const DOS437: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
|
||||
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
|
||||
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}', '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{255C}', '\u{255B}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}', '\u{255F}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}',
|
||||
'\u{2558}', '\u{2552}', '\u{2553}', '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}', '\u{2580}',
|
||||
'\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}', '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}',
|
||||
'\u{221E}', '\u{03C6}', '\u{03B5}', '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}', '\u{2248}',
|
||||
'\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
const DOS850: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
|
||||
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
|
||||
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00F8}', '\u{00A3}', '\u{00D8}', '\u{00D7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{00AE}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{00C0}', '\u{00A9}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{00A2}', '\u{00A5}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{00E3}', '\u{00C3}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{00F0}', '\u{00D0}', '\u{00CA}', '\u{00CB}',
|
||||
'\u{00C8}', '\u{0131}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{00A6}', '\u{00CC}', '\u{2580}',
|
||||
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{00D2}', '\u{00F5}', '\u{00D5}', '\u{00B5}', '\u{00FE}', '\u{00DE}', '\u{00DA}', '\u{00DB}', '\u{00D9}',
|
||||
'\u{00FD}', '\u{00DD}', '\u{00AF}', '\u{00B4}', '\u{00AD}', '\u{00B1}', '\u{2017}', '\u{00BE}', '\u{00B6}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
|
||||
'\u{00B0}', '\u{00A8}', '\u{00B7}', '\u{00B9}', '\u{00B3}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
const DOS852: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{016F}', '\u{0107}', '\u{00E7}', '\u{0142}', '\u{00EB}', '\u{0150}', '\u{0151}',
|
||||
'\u{00EE}', '\u{0179}', '\u{00C4}', '\u{0106}', '\u{00C9}', '\u{0139}', '\u{013A}', '\u{00F4}', '\u{00F6}', '\u{013D}', '\u{013E}', '\u{015A}',
|
||||
'\u{015B}', '\u{00D6}', '\u{00DC}', '\u{0164}', '\u{0165}', '\u{0141}', '\u{00D7}', '\u{010D}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{0104}', '\u{0105}', '\u{017D}', '\u{017E}', '\u{0118}', '\u{0119}', '\u{00AC}', '\u{017A}', '\u{010C}', '\u{015F}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{011A}', '\u{015E}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{017B}', '\u{017C}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{0102}', '\u{0103}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{0111}', '\u{0110}', '\u{010E}', '\u{00CB}',
|
||||
'\u{010F}', '\u{0147}', '\u{00CD}', '\u{00CE}', '\u{011B}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{0162}', '\u{016E}', '\u{2580}',
|
||||
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{0143}', '\u{0144}', '\u{0148}', '\u{0160}', '\u{0161}', '\u{0154}', '\u{00DA}', '\u{0155}', '\u{0170}',
|
||||
'\u{00FD}', '\u{00DD}', '\u{0163}', '\u{00B4}', '\u{00AD}', '\u{02DD}', '\u{02DB}', '\u{02C7}', '\u{02D8}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
|
||||
'\u{00B0}', '\u{00A8}', '\u{02D9}', '\u{0171}', '\u{0158}', '\u{0159}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn code_page_names() {
|
||||
let d = |bytes: &[u8], cp: &str| decode_code_page(bytes, cp);
|
||||
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", "ANSI_1251").as_deref(), Some("Привет"));
|
||||
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", " ansi_1251 ").as_deref(), Some("Привет"));
|
||||
assert_eq!(d(b"\x90\x7D\x96\xCA", "ANSI_932").as_deref(), Some("図面"));
|
||||
assert_eq!(d(b"\xCD\xBC\xB2\xE3", "GB2312").as_deref(), Some("图层"));
|
||||
assert_eq!(d(b"\xB9\xCF\xBC\x68", "BIG5").as_deref(), Some("圖層"));
|
||||
assert_eq!(d(b"\xB5\xB5\xB8\xE9", "ANSI_949").as_deref(), Some("도면"));
|
||||
assert_eq!(d(b"\xD3\xF7\xDD\xE4\xE9\xEF", "ANSI_1253").as_deref(), Some("Σχέδιο"));
|
||||
assert_eq!(d(b"\xC0\xD2\xBE", "ANSI_874").as_deref(), Some("ภาพ"));
|
||||
assert_eq!(d(b"\x80", "ANSI_1252").as_deref(), Some("€"));
|
||||
// DOS 437 and 850 agree on letters but differ on 0x9B (¢ / ø).
|
||||
assert_eq!(d(b"Gr\x94\xE1e \x9B", "DOS437").as_deref(), Some("Größe ¢"));
|
||||
assert_eq!(d(b"Gr\x94\xE1e \x9B", "dos850").as_deref(), Some("Größe ø"));
|
||||
assert_eq!(d(b"\xBD\xA2\x88w", "DOS852").as_deref(), Some("Żółw"));
|
||||
assert_eq!(d(b"\x91\xAB\xAE\xA9", "DOS866").as_deref(), Some("Слой"));
|
||||
assert_eq!(d("é".as_bytes(), "UTF8").as_deref(), Some("é"));
|
||||
assert_eq!(d(b"x", "ANSI_1361"), None);
|
||||
assert_eq!(d(b"x", "EBCDIC"), None);
|
||||
assert_eq!(d(b"x", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sequences_become_replacement_characters() {
|
||||
// A Shift-JIS lead byte cut off by the line end keeps the line end.
|
||||
assert_eq!(decode_code_page(b"\x90\n1\n", "ANSI_932").as_deref(), Some("\u{FFFD}\n1\n"));
|
||||
assert_eq!(decode_code_page(b"\x90", "ANSI_932").as_deref(), Some("\u{FFFD}"));
|
||||
assert_eq!(decode_code_page(b"\xFF\xFF", "ANSI_936").as_deref(), Some("\u{FFFD}\u{FFFD}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_scan() {
|
||||
let head = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\r\n0\nENDSEC\n";
|
||||
assert_eq!(ascii_code_page(head), Some(&b"ANSI_1251"[..]));
|
||||
// Only the HEADER section counts.
|
||||
assert_eq!(ascii_code_page(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n9\n$DWGCODEPAGE\n3\nANSI_1251\n"), None);
|
||||
// Wrong group code, or the file ends early.
|
||||
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n1\nANSI_1251\n"), None);
|
||||
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n3"), None);
|
||||
assert!(is_utf8_version("AC1021") && is_utf8_version(" AC1032 "));
|
||||
assert!(!is_utf8_version("AC1018") && !is_utf8_version("AC1009") && !is_utf8_version("garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mif_escapes() {
|
||||
let one = |s: &str| crate::parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap()[0].str();
|
||||
assert_eq!(one("\\M+5BCFE\\M+1907D\\M+2B9CF\\M+3B5B5!"), "件図圖도!");
|
||||
assert_eq!(one("\\M+5BCFE\\U+0021"), "件!");
|
||||
// Johab, unknown code pages, bad hex, short escapes and unassigned codes stay literal.
|
||||
for s in ["\\M+4ABCD", "\\M+9BCFE", "\\M+5GGGG", "\\M+5BC", "\\M+", "\\M+50041", "\\M+5FFFF"] {
|
||||
assert_eq!(one(s), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,925 +0,0 @@
|
||||
//! A standalone DXF tag reader and writer.
|
||||
//!
|
||||
//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a
|
||||
//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping
|
||||
//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF
|
||||
//! Reference; depends on nothing else in the workspace.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
mod version;
|
||||
pub use version::Version;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DxfError {
|
||||
#[error("not a DXF file")]
|
||||
NotDxf,
|
||||
#[error("line {line}: bad group code `{text}`")]
|
||||
BadCode { line: usize, text: String },
|
||||
#[error("unexpected end of file")]
|
||||
Eof,
|
||||
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
|
||||
/// limit was reached), `limit` the maximum.
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
|
||||
TooLarge { tags: usize, limit: usize },
|
||||
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
|
||||
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
|
||||
OutOfMemory { tags: usize },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DxfError>;
|
||||
|
||||
/// A typed group value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Real(f64),
|
||||
Int(i64),
|
||||
Bool(bool),
|
||||
/// Binary chunk (310–319, 1004) as hex text.
|
||||
Hex(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn as_str(&self) -> String {
|
||||
match self {
|
||||
Value::Str(s) | Value::Hex(s) => s.clone(),
|
||||
Value::Real(r) => format_real(*r),
|
||||
Value::Int(i) => i.to_string(),
|
||||
Value::Bool(b) => i64::from(*b).to_string(),
|
||||
}
|
||||
}
|
||||
pub fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Value::Real(r) if r.is_finite() => *r,
|
||||
Value::Int(i) => *i as f64,
|
||||
Value::Str(s) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
Value::Bool(b) => f64::from(u8::from(*b)),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
match self {
|
||||
Value::Int(i) => *i,
|
||||
Value::Real(r) if r.is_finite() => *r as i64,
|
||||
Value::Str(s) => {
|
||||
s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64))
|
||||
}
|
||||
Value::Bool(b) => i64::from(*b),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub code: i32,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn new(code: i32, value: Value) -> Self {
|
||||
Tag { code, value }
|
||||
}
|
||||
pub fn s(code: i32, v: impl Into<String>) -> Self {
|
||||
Tag { code, value: Value::Str(v.into()) }
|
||||
}
|
||||
pub fn f(code: i32, v: f64) -> Self {
|
||||
Tag { code, value: Value::Real(if v.is_finite() { v } else { 0.0 }) }
|
||||
}
|
||||
pub fn i(code: i32, v: i64) -> Self {
|
||||
Tag { code, value: Value::Int(v) }
|
||||
}
|
||||
pub fn str(&self) -> String {
|
||||
self.value.as_str()
|
||||
}
|
||||
pub fn f64(&self) -> f64 {
|
||||
self.value.as_f64()
|
||||
}
|
||||
pub fn i64(&self) -> i64 {
|
||||
self.value.as_i64()
|
||||
}
|
||||
}
|
||||
|
||||
/// The value type for a group code (DXF Reference, "Group Code Value Types").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Str,
|
||||
Real,
|
||||
Int16,
|
||||
Int32,
|
||||
Int64,
|
||||
Bool,
|
||||
Hex,
|
||||
}
|
||||
|
||||
pub fn kind_of(code: i32) -> Kind {
|
||||
match code {
|
||||
0..=9 => Kind::Str,
|
||||
10..=59 => Kind::Real,
|
||||
60..=79 => Kind::Int16,
|
||||
90..=99 => Kind::Int32,
|
||||
100..=109 => Kind::Str,
|
||||
110..=149 => Kind::Real,
|
||||
160..=169 => Kind::Int64,
|
||||
170..=179 => Kind::Int16,
|
||||
210..=239 => Kind::Real,
|
||||
270..=289 => Kind::Int16,
|
||||
290..=299 => Kind::Bool,
|
||||
300..=309 => Kind::Str,
|
||||
310..=319 => Kind::Hex,
|
||||
320..=369 => Kind::Str,
|
||||
370..=389 => Kind::Int16,
|
||||
390..=399 => Kind::Str,
|
||||
400..=409 => Kind::Int16,
|
||||
410..=419 => Kind::Str,
|
||||
420..=429 => Kind::Int32,
|
||||
430..=439 => Kind::Str,
|
||||
440..=449 => Kind::Int32,
|
||||
450..=459 => Kind::Int32,
|
||||
460..=469 => Kind::Real,
|
||||
470..=479 => Kind::Str,
|
||||
480..=481 => Kind::Str,
|
||||
999 => Kind::Str,
|
||||
1000..=1003 => Kind::Str,
|
||||
1004 => Kind::Hex,
|
||||
1005..=1009 => Kind::Str,
|
||||
1010..=1059 => Kind::Real,
|
||||
1060..=1070 => Kind::Int16,
|
||||
1071 => Kind::Int32,
|
||||
_ => Kind::Str,
|
||||
}
|
||||
}
|
||||
|
||||
fn typed(code: i32, text: &str) -> Value {
|
||||
let t = text.trim();
|
||||
match kind_of(code) {
|
||||
Kind::Real => t.parse::<f64>().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
|
||||
Kind::Int16 | Kind::Int32 | Kind::Int64 => {
|
||||
// A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the
|
||||
// group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as
|
||||
// the integer spelling reads them); NaN, inf and text become 0.
|
||||
let (lo, hi) = match kind_of(code) {
|
||||
Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)),
|
||||
Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)),
|
||||
_ => (i64::MIN, i64::MAX),
|
||||
};
|
||||
t.parse::<i64>()
|
||||
.map(Value::Int)
|
||||
.unwrap_or_else(|_| Value::Int(t.parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi))))
|
||||
}
|
||||
Kind::Bool => Value::Bool(t.parse::<i64>().unwrap_or(0) != 0),
|
||||
Kind::Hex => Value::Hex(normalize_hex(t)),
|
||||
Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
|
||||
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
|
||||
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
|
||||
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
|
||||
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
|
||||
/// raise or lower it.
|
||||
pub const MAX_TAGS: usize = 100_000_000;
|
||||
|
||||
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
|
||||
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
|
||||
|
||||
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
|
||||
/// [`MAX_TAGS`].
|
||||
pub fn max_tags() -> usize {
|
||||
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn max_tags_from(v: Option<&str>) -> usize {
|
||||
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
|
||||
}
|
||||
|
||||
mod codepage;
|
||||
pub use codepage::decode_code_page;
|
||||
|
||||
/// Parse a DXF file (ASCII or binary).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_with_limit(bytes, max_tags())
|
||||
}
|
||||
|
||||
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
|
||||
/// the line count, before the text is decoded or any tag allocated, so an oversized file fails in
|
||||
/// milliseconds instead of after it has filled memory.
|
||||
pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes);
|
||||
// Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at
|
||||
// EOF just as the parser does. Blank lines do not count toward the limit.
|
||||
let mut lines = byte_lines(bytes);
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
if code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = lines.next() else { break };
|
||||
tags += 1;
|
||||
if code == b"0" && value.trim_ascii() == b"EOF" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
|
||||
}
|
||||
|
||||
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
|
||||
fn escape_at(chars: &[char], i: usize) -> Option<u32> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'U') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let hex = chars.get(i + 3..i + 7)?;
|
||||
if !hex.iter().all(char::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
hex.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
|
||||
}
|
||||
|
||||
/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a
|
||||
/// double-byte code page and `XXXX` is the character's two bytes in hex.
|
||||
pub fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let enc = codepage::mif_encoding(*chars.get(i + 3)?)?;
|
||||
let hex = chars.get(i + 4..i + 8)?;
|
||||
if !hex.iter().all(char::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
let code = hex.iter().try_fold(0u16, |acc, c| Some(acc * 16 + u16::try_from(c.to_digit(16)?).ok()?))?;
|
||||
let bytes = code.to_be_bytes();
|
||||
let text = enc.decode_without_bom_handling_and_without_replacement(&bytes)?;
|
||||
let mut it = text.chars();
|
||||
it.next().filter(|_| it.next().is_none())
|
||||
}
|
||||
|
||||
/// Unescape the `\U+XXXX` sequences pre-R2007 DXF uses for characters outside the file's code
|
||||
/// page (four hex digits, one UTF-16 code unit; characters above U+FFFF are a surrogate pair of
|
||||
/// escapes), and the `\M+nXXXX` sequences for double-byte East Asian characters. Malformed
|
||||
/// escapes, unpaired surrogates and unknown `\M+` code pages are kept as literal text.
|
||||
fn unescape_unicode(s: &str) -> String {
|
||||
if !s.contains("\\U+") && !s.contains("\\M+") {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if let Some(ch) = mif_escape_at(&chars, i) {
|
||||
out.push(ch);
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
if let Some(unit) = escape_at(&chars, i) {
|
||||
if let Some(ch) = char::from_u32(unit) {
|
||||
out.push(ch);
|
||||
i += 7;
|
||||
continue;
|
||||
}
|
||||
if (0xD800..0xDC00).contains(&unit)
|
||||
&& let Some(low) = escape_at(&chars, i + 7).filter(|u| (0xDC00..0xE000).contains(u))
|
||||
&& let Some(ch) = char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00))
|
||||
{
|
||||
out.push(ch);
|
||||
i += 14;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like
|
||||
/// [`str::lines`] otherwise (no empty line after a final terminator).
|
||||
fn ascii_lines(text: &str) -> impl Iterator<Item = &str> {
|
||||
let mut rest = text;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
|
||||
let line = rest.get(..end).unwrap_or_default();
|
||||
let after = rest.get(end..).unwrap_or_default();
|
||||
rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after);
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser.
|
||||
fn byte_lines(mut rest: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len());
|
||||
let line = &rest[..end];
|
||||
let after = &rest[end..];
|
||||
rest = match after {
|
||||
[b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail,
|
||||
_ => after,
|
||||
};
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
parse_ascii_counted(text, max_tags(), 0)
|
||||
}
|
||||
|
||||
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
|
||||
/// with an allocation that can fail: where the system refuses that much memory, opening the file
|
||||
/// is an error instead of the end of the app. Growing the list instead could also briefly take
|
||||
/// half as much again.
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
|
||||
let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
let ct = code_line.trim();
|
||||
if ct.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let code: i32 = ct.parse().map_err(|_| DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() })?;
|
||||
let Some((_, val)) = lines.next() else { return Err(DxfError::Eof) };
|
||||
let v = typed(code, val);
|
||||
let v = match v {
|
||||
Value::Str(s) => Value::Str(unescape_unicode(&s)),
|
||||
o => o,
|
||||
};
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags.is_empty() {
|
||||
return Err(DxfError::NotDxf);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
struct Cur<'a> {
|
||||
b: &'a [u8],
|
||||
p: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cur<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&[u8]> {
|
||||
let end = self.p.checked_add(n).ok_or(DxfError::Eof)?;
|
||||
let s = self.b.get(self.p..end).ok_or(DxfError::Eof)?;
|
||||
self.p = end;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8> {
|
||||
Ok(*self.take(1)?.first().ok_or(DxfError::Eof)?)
|
||||
}
|
||||
fn i16(&mut self) -> Result<i16> {
|
||||
let s = self.take(2)?;
|
||||
Ok(i16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
fn i32(&mut self) -> Result<i32> {
|
||||
let s = self.take(4)?;
|
||||
Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64> {
|
||||
let s = self.take(8)?;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
Ok(i64::from_le_bytes(a))
|
||||
}
|
||||
fn f64(&mut self) -> Result<f64> {
|
||||
let s = self.take(8)?;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
Ok(f64::from_le_bytes(a))
|
||||
}
|
||||
/// The bytes of a zero-terminated string (without the zero), decoded by the caller.
|
||||
fn cstr(&mut self) -> Result<&'a [u8]> {
|
||||
let start = self.p;
|
||||
while self.p < self.b.len() && self.b.get(self.p) != Some(&0) {
|
||||
self.p += 1;
|
||||
}
|
||||
let s = self.b.get(start..self.p).unwrap_or(&[]);
|
||||
if self.p >= self.b.len() {
|
||||
return Err(DxfError::Eof);
|
||||
}
|
||||
self.p += 1;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary DXF (R13+: 2-byte group codes).
|
||||
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_binary_with_limit(b, max_tags())
|
||||
}
|
||||
|
||||
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut text = codepage::BinaryText::default();
|
||||
let tags = parse_binary_pass(b, max_tags, &mut text)?;
|
||||
if !text.needs_code_page_pass() {
|
||||
return Ok(tags);
|
||||
}
|
||||
// The first pass's tags go before the second pass runs: both held at once would double
|
||||
// the peak near the tag limit.
|
||||
drop(tags);
|
||||
parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass())
|
||||
}
|
||||
|
||||
fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result<Vec<Tag>> {
|
||||
let mut c = Cur { b, p: 0 };
|
||||
let mut tags = Vec::new();
|
||||
while c.p < b.len() {
|
||||
let mut code = i32::from(c.i16()?);
|
||||
if code == 255 {
|
||||
code = i32::from(c.i16()?);
|
||||
}
|
||||
let v = match kind_of(code) {
|
||||
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
|
||||
Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)),
|
||||
Kind::Int16 => Value::Int(i64::from(c.i16()?)),
|
||||
Kind::Int32 => Value::Int(i64::from(c.i32()?)),
|
||||
Kind::Int64 => Value::Int(c.i64()?),
|
||||
Kind::Bool => Value::Bool(c.u8()? != 0),
|
||||
Kind::Hex => {
|
||||
let n = usize::from(c.u8()?);
|
||||
let bytes = c.take(n)?;
|
||||
let mut s = String::with_capacity(n * 2);
|
||||
for x in bytes {
|
||||
let _ = write!(s, "{x:02X}");
|
||||
}
|
||||
Value::Hex(s)
|
||||
}
|
||||
};
|
||||
let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF");
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags.is_empty() {
|
||||
return Err(DxfError::NotDxf);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point).
|
||||
pub fn format_real(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0.0".into();
|
||||
}
|
||||
let s = format!("{v}");
|
||||
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") }
|
||||
}
|
||||
|
||||
/// Write tags as ASCII DXF (CRLF line ends, codes right-aligned to 3 as is conventional).
|
||||
///
|
||||
/// The output is pure ASCII: non-ASCII characters in strings are written as `\U+XXXX` escapes,
|
||||
/// the pre-R2007 convention, so it is correct whatever `$DWGCODEPAGE` the header declares.
|
||||
pub fn write_ascii(tags: &[Tag]) -> String {
|
||||
write_ascii_as(tags, false)
|
||||
}
|
||||
|
||||
/// Write tags as ASCII DXF with text in UTF-8 (`utf8`, the R2007+ convention: non-ASCII
|
||||
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
|
||||
/// breaks inside strings become spaces either way.
|
||||
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
|
||||
// Both forms are UTF-8 (the escaped one is ASCII).
|
||||
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
|
||||
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
|
||||
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
|
||||
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
|
||||
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
|
||||
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
|
||||
/// DXF that is valid UTF-8 as UTF-8.
|
||||
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
|
||||
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
|
||||
return ascii(tags, Strings::Escaped(None));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// How a writer stores string values.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Strings {
|
||||
/// As they are (R2007+).
|
||||
Utf8,
|
||||
/// ASCII as it is, other characters in this code page when it has them, otherwise as
|
||||
/// `\U+XXXX` escapes (before R2007).
|
||||
Escaped(Option<codepage::Decoder>),
|
||||
}
|
||||
|
||||
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(tags.len() * 16);
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) => push_string(s, strings, &mut out),
|
||||
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
|
||||
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
|
||||
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
|
||||
Value::Hex(h) => {
|
||||
// The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes);
|
||||
// longer data continues in further groups of the same code. Only hex digits are
|
||||
// written, so a hostile value can't inject tags.
|
||||
let digits = normalize_hex(h);
|
||||
let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push("");
|
||||
}
|
||||
for (n, chunk) in chunks.into_iter().enumerate() {
|
||||
if n > 0 {
|
||||
let _ = write!(out, "\r\n{:>3}\r\n", t.code);
|
||||
}
|
||||
out.extend_from_slice(chunk.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write tags as binary DXF (DXF Reference, "Binary DXF Files", R13+ layout): the sentinel,
|
||||
/// then per tag a 2-byte little-endian group code and the value in the binary form of the code's
|
||||
/// [`Kind`]: a zero-terminated string, an 8-byte real, a 2-, 4- or 8-byte integer, a 1-byte
|
||||
/// boolean, or a binary chunk (one length byte, then up to 255 bytes; longer data is split into
|
||||
/// several groups of the same code). Strings are UTF-8 when `utf8` (R2007+), otherwise escaped as
|
||||
/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text
|
||||
/// is stored as a number; a code outside the 16-bit range is skipped.
|
||||
pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) })
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as
|
||||
/// [`write_ascii_in`] does.
|
||||
pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
|
||||
}
|
||||
|
||||
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
|
||||
out.extend_from_slice(BINARY_SENTINEL);
|
||||
for t in tags {
|
||||
let Ok(code) = i16::try_from(t.code) else { continue };
|
||||
let code_bytes = code.to_le_bytes();
|
||||
match kind_of(t.code) {
|
||||
Kind::Str => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
let s = match &t.value {
|
||||
Value::Str(s) => s.clone(),
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = Vec::with_capacity(s.len());
|
||||
push_string(&s, strings, &mut text);
|
||||
out.extend(text.into_iter().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
Kind::Real => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&t.value.as_f64().to_le_bytes());
|
||||
}
|
||||
Kind::Int16 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
|
||||
}
|
||||
Kind::Int32 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
|
||||
}
|
||||
Kind::Int64 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&t.value.as_i64().to_le_bytes());
|
||||
}
|
||||
Kind::Bool => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.push(u8::from(t.value.as_i64() != 0));
|
||||
}
|
||||
Kind::Hex => {
|
||||
let bytes = hex_bytes(&t.value.as_str());
|
||||
// An empty chunk is still one group, as in ASCII.
|
||||
let mut chunks: Vec<&[u8]> = bytes.chunks(255).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push(&[]);
|
||||
}
|
||||
for chunk in chunks {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.push(u8::try_from(chunk.len()).unwrap_or(u8::MAX));
|
||||
out.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with
|
||||
/// `0` (so the bytes before it are never shifted). Used by the reader and both writers.
|
||||
fn normalize_hex(s: &str) -> String {
|
||||
let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect();
|
||||
if out.len() % 2 == 1 {
|
||||
out.push('0');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of hex text, two digits each (see [`normalize_hex`]).
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
let digits: Vec<u8> = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
|
||||
digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect()
|
||||
}
|
||||
|
||||
/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the
|
||||
/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps.
|
||||
fn wrap_i16(v: i64) -> [u8; 2] {
|
||||
match u16::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_i32(v: i64) -> [u8; 4] {
|
||||
match u32::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a binary DXF file (they start with the binary sentinel).
|
||||
pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(BINARY_SENTINEL)
|
||||
}
|
||||
|
||||
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
|
||||
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
|
||||
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
|
||||
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
|
||||
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
|
||||
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
|
||||
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
|
||||
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
|
||||
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match (c, strings) {
|
||||
('\r' | '\n', _) => out.push(b' '),
|
||||
('\\', _) if escape_at(i + 1) => {
|
||||
out.extend_from_slice(b"\\U+005C");
|
||||
}
|
||||
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
|
||||
(c, Strings::Escaped(_)) => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
let _ = write!(out, "\\U+{u:04X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
|
||||
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
|
||||
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
|
||||
let start = out.len();
|
||||
if !codepage::encode_char(c, d, out) {
|
||||
return false;
|
||||
}
|
||||
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
|
||||
out.truncate(start);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Section {
|
||||
pub name: String,
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
/// Split a tag stream into sections.
|
||||
pub fn sections(tags: &[Tag]) -> Vec<Section> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tags.len() {
|
||||
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION");
|
||||
if is_sec {
|
||||
let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default();
|
||||
let start = i + 2;
|
||||
let mut j = start;
|
||||
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) {
|
||||
j += 1;
|
||||
}
|
||||
out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a run of tags into records starting at each code-0 tag: (type, tags after the 0).
|
||||
pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
|
||||
let mut out: Vec<(String, Vec<Tag>)> = Vec::new();
|
||||
for t in tags {
|
||||
if t.code == 0 {
|
||||
out.push((t.str(), Vec::new()));
|
||||
} else if let Some(last) = out.last_mut() {
|
||||
last.1.push(t.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_escape_text_roundtrips() {
|
||||
for utf8 in [false, true] {
|
||||
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
|
||||
let tags = vec![Tag::s(8, text)];
|
||||
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
|
||||
assert_eq!(back, tags, "utf8={utf8} {text}");
|
||||
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
|
||||
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_do_not_consume_group_code_limit() {
|
||||
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
|
||||
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "LINE"),
|
||||
Tag::s(8, "0"),
|
||||
Tag::f(10, 1.5),
|
||||
Tag::f(20, 2.0),
|
||||
Tag::i(62, 1),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let text = write_ascii(&tags);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back, tags);
|
||||
let secs = sections(&back);
|
||||
assert_eq!(secs.len(), 1);
|
||||
assert_eq!(secs[0].name, "ENTITIES");
|
||||
let recs = records(&secs[0].tags);
|
||||
assert_eq!(recs[0].0, "LINE");
|
||||
assert_eq!(recs[0].1.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_parse() {
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
let push_str = |b: &mut Vec<u8>, code: i16, s: &str| {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s.as_bytes());
|
||||
b.push(0);
|
||||
};
|
||||
push_str(&mut b, 0, "LINE");
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&3.25f64.to_le_bytes());
|
||||
b.extend_from_slice(&62i16.to_le_bytes());
|
||||
b.extend_from_slice(&5i16.to_le_bytes());
|
||||
push_str(&mut b, 0, "EOF");
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[1], Tag::f(10, 3.25));
|
||||
assert_eq!(tags[2], Tag::i(62, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs() {
|
||||
assert!(parse(b"").is_err());
|
||||
assert!(parse(b"abc\nxyz\n").is_err());
|
||||
assert!(parse(b"0\n").is_err());
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&[1, 2, 3]);
|
||||
assert!(parse(&b).is_err());
|
||||
// Bad numbers become 0 rather than failing.
|
||||
let t = parse(b"10\nnotanumber\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].f64(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_and_latin1() {
|
||||
let t = parse(b"1\nCaf\\U+00E9\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "Café");
|
||||
let t = parse(b"1\nCaf\xe9\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrogate_pair_escapes() {
|
||||
let t = parse(b"1\n\\U+D83D\\U+DE00 \\u+56fe\\U+53f7\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "😀 \\u+56fe号");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_escapes_stay_literal() {
|
||||
for s in [
|
||||
"\\U+",
|
||||
"\\U",
|
||||
"\\",
|
||||
"a\\U+ZZZZ",
|
||||
"\\U+12",
|
||||
"\\U+D800",
|
||||
"\\U+DC00",
|
||||
"\\U+D800\\U+0041",
|
||||
"\\U+D83D\\U+DE",
|
||||
"\\U++123",
|
||||
"\\U+-123",
|
||||
"x\\U+12\u{e9}4",
|
||||
] {
|
||||
let t = parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap();
|
||||
let expect = if s == "\\U+D800\\U+0041" { "\\U+D800A" } else { s };
|
||||
assert_eq!(t[0].str(), expect, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_non_ascii_as_escapes() {
|
||||
let tags = vec![Tag::s(1, "图号 İø 😀\r\nend"), Tag::s(1000, "Ø"), Tag::s(0, "EOF")];
|
||||
let text = write_ascii(&tags);
|
||||
assert!(text.is_ascii());
|
||||
assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+D83D\\U+DE00 end\r\n"));
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back[0].str(), "图号 İø 😀 end");
|
||||
assert_eq!(back[1].str(), "Ø");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_strings_unescape() {
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"\\U+56FE\\U+53F7\0");
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_limit_defaults_and_env_override() {
|
||||
assert_eq!(max_tags_from(None), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
|
||||
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
|
||||
const { assert!(MAX_TAGS >= 100_000_000) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
|
||||
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
|
||||
assert_eq!((tags.len(), tags.capacity()), (3, 3));
|
||||
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//! The DXF/DWG file versions CADCraft writes (DXF Reference, HEADER `$ACADVER`).
|
||||
|
||||
/// A file version CADCraft can save: the AutoCAD release that introduced the format. Each format
|
||||
/// also serves the releases after it (2018 is still AutoCAD's current format).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Version {
|
||||
/// AutoCAD 2000/2002 (AC1015).
|
||||
R2000,
|
||||
/// AutoCAD 2004–2006 (AC1018).
|
||||
R2004,
|
||||
/// AutoCAD 2007–2009 (AC1021): the first version whose DXF text is UTF-8.
|
||||
R2007,
|
||||
/// AutoCAD 2010–2012 (AC1024).
|
||||
R2010,
|
||||
/// AutoCAD 2013–2017 (AC1027).
|
||||
R2013,
|
||||
/// AutoCAD 2018 and later (AC1032), the default.
|
||||
#[default]
|
||||
R2018,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// Every version, oldest first.
|
||||
pub const ALL: [Version; 6] = [Version::R2000, Version::R2004, Version::R2007, Version::R2010, Version::R2013, Version::R2018];
|
||||
|
||||
/// The `$ACADVER` code ("AC1032").
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Version::R2000 => "AC1015",
|
||||
Version::R2004 => "AC1018",
|
||||
Version::R2007 => "AC1021",
|
||||
Version::R2010 => "AC1024",
|
||||
Version::R2013 => "AC1027",
|
||||
Version::R2018 => "AC1032",
|
||||
}
|
||||
}
|
||||
|
||||
/// The release year that names the version ("2018").
|
||||
pub fn year(self) -> &'static str {
|
||||
match self {
|
||||
Version::R2000 => "2000",
|
||||
Version::R2004 => "2004",
|
||||
Version::R2007 => "2007",
|
||||
Version::R2010 => "2010",
|
||||
Version::R2013 => "2013",
|
||||
Version::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// A version named by its year ("2018", "R2018") or its `$ACADVER` code ("AC1032"), any case.
|
||||
pub fn parse(s: &str) -> Option<Version> {
|
||||
let s = s.trim();
|
||||
let year = s.strip_prefix(['R', 'r']).unwrap_or(s);
|
||||
Version::ALL.into_iter().find(|v| v.year() == year || v.code().eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// The version to save a file of `$ACADVER` `code` in: the same version when CADCraft writes
|
||||
/// it, else the nearest one it does (R2000 for older files, 2018 for newer ones); `None` when
|
||||
/// the code isn't a version code at all.
|
||||
pub fn for_acadver(code: &str) -> Option<Version> {
|
||||
let code = code.trim();
|
||||
if let Some(v) = Version::ALL.into_iter().find(|v| v.code().eq_ignore_ascii_case(code)) {
|
||||
return Some(v);
|
||||
}
|
||||
code.get(..2).filter(|p| p.eq_ignore_ascii_case("AC"))?;
|
||||
let n = code.get(2..).filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse::<u32>().ok())?;
|
||||
Some(match n {
|
||||
..1018 => Version::R2000,
|
||||
1018..1021 => Version::R2004,
|
||||
1021..1024 => Version::R2007,
|
||||
1024..1027 => Version::R2010,
|
||||
1027..1032 => Version::R2013,
|
||||
_ => Version::R2018,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether text in this version's DXF is UTF-8 (2007 and later). Older versions keep text in
|
||||
/// the `$DWGCODEPAGE` code page, which CADCraft avoids by escaping non-ASCII as `\U+XXXX`.
|
||||
pub fn utf8(self) -> bool {
|
||||
self >= Version::R2007
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.year())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_years_and_codes() {
|
||||
assert_eq!(Version::parse("2018"), Some(Version::R2018));
|
||||
assert_eq!(Version::parse(" r2004 "), Some(Version::R2004));
|
||||
assert_eq!(Version::parse("ac1021"), Some(Version::R2007));
|
||||
assert_eq!(Version::parse("2005"), None);
|
||||
assert_eq!(Version::parse(""), None);
|
||||
for v in Version::ALL {
|
||||
assert_eq!(Version::parse(v.code()), Some(v));
|
||||
assert_eq!(Version::parse(v.year()), Some(v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_versions_map_to_the_nearest_writable_one() {
|
||||
assert_eq!(Version::for_acadver("AC1009"), Some(Version::R2000));
|
||||
assert_eq!(Version::for_acadver("AC1014"), Some(Version::R2000));
|
||||
assert_eq!(Version::for_acadver("AC1024"), Some(Version::R2010));
|
||||
assert_eq!(Version::for_acadver("AC1035"), Some(Version::R2018));
|
||||
assert_eq!(Version::for_acadver("MC0.0"), None);
|
||||
assert_eq!(Version::for_acadver("AC"), None);
|
||||
assert_eq!(Version::for_acadver("AC99999999999999999999"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
|
||||
|
||||
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_bytes_are_skipped() {
|
||||
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(BODY.as_bytes());
|
||||
let with_bom = cadcraft_dxf::parse(&bytes);
|
||||
assert!(with_bom.is_ok(), "{with_bom:?}");
|
||||
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bom_text_is_skipped() {
|
||||
let text = format!("\u{feff}{BODY}");
|
||||
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! Binary DXF writing (#421): every value kind round-trips through `write_binary` and `parse`,
|
||||
//! and hostile binary input never panics.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn sample() -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(5, "1F"),
|
||||
Tag::f(10, -3.25),
|
||||
Tag::i(62, 5),
|
||||
Tag::i(90, 70_000),
|
||||
Tag::i(160, 1 << 40),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FFA5".into())),
|
||||
Tag::s(1, "图号 Café 😀"),
|
||||
Tag::i(1071, -2),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrips_every_value_kind() {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = write_binary(&sample(), utf8);
|
||||
assert!(is_binary(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap(), sample(), "utf8 = {utf8}");
|
||||
}
|
||||
// Pre-2007 strings are escaped to ASCII, 2007+ ones are UTF-8.
|
||||
let old = write_binary(&sample(), false);
|
||||
assert!(old.windows(7).any(|w| w == b"\\U+56FE"));
|
||||
let new = write_binary(&sample(), true);
|
||||
assert!(new.windows("图号".len()).any(|w| w == "图号".as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_converts_values_to_their_codes_kind() {
|
||||
// A number held as text at a numeric code, an out-of-range 16-bit value, a long chunk.
|
||||
let long = "AB".repeat(300);
|
||||
let tags = vec![Tag::s(70, "12"), Tag::i(62, 1 << 20), Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, 12));
|
||||
assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX)));
|
||||
assert_eq!(back[2].code, 310);
|
||||
assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255");
|
||||
assert_eq!(back[2].str().len() + back[3].str().len(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_ascii_keeps_text_and_drops_line_breaks() {
|
||||
let text = write_ascii_as(&[Tag::s(1, "图号\r\nÄ"), Tag::s(0, "EOF")], true);
|
||||
assert!(text.contains("图号 Ä\r\n"));
|
||||
assert_eq!(parse(text.as_bytes()).unwrap()[0].str(), "图号 Ä");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_binary_input_never_panics() {
|
||||
let good = write_binary(&sample(), true);
|
||||
// Every truncation, and every single-byte corruption of the body.
|
||||
for n in 0..good.len() {
|
||||
let _ = parse(good.get(..n).unwrap());
|
||||
}
|
||||
for i in 22..good.len() {
|
||||
for b in [0u8, 0x7f, 0xff] {
|
||||
let mut bad = good.clone();
|
||||
bad[i] = b;
|
||||
let _ = parse(&bad);
|
||||
}
|
||||
}
|
||||
// A sentinel and nothing else is not a drawing.
|
||||
assert!(parse(b"AutoCAD Binary DXF\r\n\x1a\0").is_err());
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
|
||||
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
|
||||
|
||||
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
|
||||
|
||||
fn file(code_page: &str, text: &str) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, "AC1015"),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, code_page),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(1, text),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
fn contains(bytes: &[u8], part: &[u8]) -> bool {
|
||||
bytes.windows(part.len()).any(|w| w == part)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_written_in_the_code_page() {
|
||||
// (code page, text, the bytes written for it)
|
||||
let cases: [(&str, &str, &[u8]); 6] = [
|
||||
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
|
||||
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
|
||||
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
|
||||
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
|
||||
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
|
||||
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
|
||||
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
|
||||
];
|
||||
for (cp, text, raw) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
|
||||
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
|
||||
#[test]
|
||||
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
|
||||
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
|
||||
let tags = file(cp, "Ação\r\nfim");
|
||||
let bytes = write_ascii_in(&tags, cp);
|
||||
assert!(bytes.is_ascii(), "{cp}");
|
||||
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
|
||||
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
|
||||
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
|
||||
}
|
||||
}
|
||||
|
||||
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
|
||||
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
|
||||
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
|
||||
#[test]
|
||||
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
|
||||
let tags = file("ANSI_1251", "СІ");
|
||||
let ascii = write_ascii_in(&tags, "ANSI_1251");
|
||||
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
|
||||
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
|
||||
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
|
||||
let binary = write_binary_in(&tags, "ANSI_1251");
|
||||
assert!(contains(&binary, b"\xD1\xB2"));
|
||||
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
|
||||
// Bytes that are not valid UTF-8 stay as they are.
|
||||
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
|
||||
}
|
||||
|
||||
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
|
||||
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
|
||||
/// would read as UTF-8.
|
||||
#[test]
|
||||
fn literal_escapes_survive_code_page_text() {
|
||||
for (cp, text) in
|
||||
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
|
||||
{
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
|
||||
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
|
||||
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
|
||||
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
|
||||
#[test]
|
||||
fn a_trail_byte_backslash_never_starts_an_escape() {
|
||||
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
|
||||
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
|
||||
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
|
||||
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
|
||||
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
|
||||
];
|
||||
for (cp, text, written, never) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
|
||||
//! Handwritten examples, not files from a CAD application or generated by our writer.
|
||||
//! Background on the tag model and file structure:
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
|
||||
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
|
||||
|
||||
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
|
||||
vec![
|
||||
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
|
||||
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
|
||||
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
|
||||
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
|
||||
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
|
||||
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
|
||||
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
|
||||
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
|
||||
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
|
||||
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
|
||||
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
|
||||
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
|
||||
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_independently_authored_ascii_and_binary_values() {
|
||||
for (expected, ascii, binary) in fixtures() {
|
||||
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
|
||||
let bytes = [SENTINEL, binary].concat();
|
||||
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_independently_specified_ascii_and_binary_bytes() {
|
||||
for (tag, ascii, binary) in fixtures() {
|
||||
for utf8 in [false, true] {
|
||||
let tags = std::slice::from_ref(&tag);
|
||||
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
|
||||
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_code_ranges_have_independent_binary_widths() {
|
||||
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
|
||||
// can round-trip every boundary value while still using the wrong number of bytes.
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, width, min, max) in widths {
|
||||
for code in ranges.into_iter().flatten() {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
|
||||
// little-endian two's-complement integer. Encoding is version-independent.
|
||||
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
|
||||
let value_bytes = value.to_le_bytes();
|
||||
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
|
||||
let tags = [Tag::i(code, value)];
|
||||
for utf8 in [false, true] {
|
||||
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
|
||||
}
|
||||
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_encoding_has_independent_wire_expectations() {
|
||||
let tags = [Tag::s(1, "é😀")];
|
||||
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
|
||||
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
|
||||
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
|
||||
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
|
||||
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
|
||||
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
|
||||
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
|
||||
assert_eq!(parse(&ascii).unwrap(), tags);
|
||||
assert_eq!(parse(&binary).unwrap(), tags);
|
||||
}
|
||||
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
|
||||
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
|
||||
let binary = [
|
||||
SENTINEL,
|
||||
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
|
||||
]
|
||||
.concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
let tags = parse(bytes).unwrap();
|
||||
assert_eq!(tags[7], Tag::s(1, "ێ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
|
||||
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
|
||||
for newline in ["\n", "\r\n"] {
|
||||
for final_newline in [false, true] {
|
||||
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
|
||||
if final_newline {
|
||||
text.push_str(newline);
|
||||
}
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_truncated_values_are_errors_not_panics() {
|
||||
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
|
||||
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
|
||||
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
|
||||
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
|
||||
for (_, _, binary) in fixtures() {
|
||||
// Every cut inside the first record is an error, unlike cuts between complete records:
|
||||
// the tag API deliberately accepts streams without an EOF record.
|
||||
for cut in 1..binary.len() {
|
||||
let bytes = [SENTINEL, &binary[..cut]].concat();
|
||||
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
|
||||
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
|
||||
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
for limit in 0..3 {
|
||||
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
|
||||
}
|
||||
for limit in [3, 4] {
|
||||
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
|
||||
let mut b = SENTINEL.to_vec();
|
||||
f(&mut b);
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reals_that_are_not_finite_become_zero() {
|
||||
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let b = bin_with(|b| {
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&bits.to_le_bytes());
|
||||
});
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_bom_and_cr_only_line_ends() {
|
||||
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
|
||||
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), want);
|
||||
let mut bom = b"\xEF\xBB\xBF".to_vec();
|
||||
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
|
||||
assert_eq!(parse(&bom).unwrap(), want);
|
||||
// Mixed terminators.
|
||||
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr_only_files_count_against_the_tag_limit() {
|
||||
let mut text = String::new();
|
||||
for _ in 0..50 {
|
||||
text.push_str("999\rcomment\r");
|
||||
}
|
||||
text.push_str("0\rEOF\r");
|
||||
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
|
||||
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_byte_spoils_one_line_not_the_file() {
|
||||
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
|
||||
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
|
||||
b.extend_from_slice("Größe".as_bytes());
|
||||
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
|
||||
let t = parse(&b).unwrap();
|
||||
assert_eq!(t[1].str(), "ANSI_1251");
|
||||
assert_eq!(t[2].str(), "Größe");
|
||||
assert_eq!(t[3].str(), "Привет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
|
||||
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
|
||||
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
|
||||
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
|
||||
let utf8 = "Größe".as_bytes();
|
||||
let latin = b"Caf\xE9";
|
||||
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
|
||||
a.extend_from_slice(utf8);
|
||||
a.extend_from_slice(b"\n1\n");
|
||||
a.extend_from_slice(latin);
|
||||
a.extend_from_slice(b"\n0\nEOF\n");
|
||||
let from_ascii = parse(&a).unwrap();
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
let from_binary = parse(&b).unwrap();
|
||||
assert_eq!(from_ascii[2].str(), "Größe");
|
||||
assert_eq!(from_ascii[3].str(), "Café");
|
||||
assert_eq!(from_binary[2].str(), "Größe");
|
||||
assert_eq!(from_binary[3].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_spaces_keep_sections_and_eof() {
|
||||
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
|
||||
// The EOF with a trailing space still ends the file.
|
||||
assert_eq!(t.len(), 5);
|
||||
let s = sections(&t);
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s[0].name, "ENTITIES");
|
||||
assert_eq!(s[0].tags.len(), 1);
|
||||
// Binary EOF too.
|
||||
let mut b = SENTINEL.to_vec();
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF \0");
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"after\0");
|
||||
assert_eq!(parse(&b).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_groups_reject_non_finite_and_clamp() {
|
||||
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], Tag::i(70, 0));
|
||||
assert_eq!(t[1], Tag::i(70, 0));
|
||||
// Clamped to what the group's width holds, unsigned values included (the binary writer
|
||||
// keeps up to 65535 and 4294967295 as their bit patterns).
|
||||
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
|
||||
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
|
||||
assert_eq!(t[5], Tag::i(160, i64::MAX));
|
||||
assert_eq!(t[6], Tag::i(160, 0));
|
||||
assert_eq!(t[7], Tag::i(62, 12));
|
||||
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
|
||||
}
|
||||
|
||||
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
|
||||
/// `65535.0` is 65535 like `65535`, not 32767.
|
||||
#[test]
|
||||
fn unsigned_values_read_the_same_in_either_spelling() {
|
||||
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], t[1]);
|
||||
assert_eq!(t[0], Tag::i(70, 65535));
|
||||
assert_eq!(t[2], t[3]);
|
||||
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_groups_pad_odd_digits_and_drop_junk() {
|
||||
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
|
||||
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
|
||||
assert_eq!(t[2].value, Value::Hex(String::new()));
|
||||
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
|
||||
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
|
||||
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
|
||||
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
|
||||
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
|
||||
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.len(), 2, "{text:?}");
|
||||
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
|
||||
let long = "AB".repeat(300);
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
|
||||
assert_eq!(sizes, vec![254, 254, 92]);
|
||||
// An empty chunk is still one group.
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
assert_eq!(back[0].code, 310);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
|
||||
let tags = [
|
||||
Tag::i(70, 65535),
|
||||
Tag::i(70, 40000),
|
||||
Tag::i(70, 70000),
|
||||
Tag::i(70, -40000),
|
||||
Tag::i(90, 0xFFFF_FFFF),
|
||||
Tag::i(90, 1 << 40),
|
||||
Tag::i(90, -(1 << 40)),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, -1));
|
||||
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
|
||||
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
|
||||
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(back[4], Tag::i(90, -1));
|
||||
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
|
||||
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
|
||||
}
|
||||
|
||||
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
|
||||
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
|
||||
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
|
||||
for s in strings {
|
||||
a.extend_from_slice(b"1\n");
|
||||
a.extend_from_slice(s);
|
||||
a.push(b'\n');
|
||||
}
|
||||
a.extend_from_slice(b"0\nEOF\n");
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
[parse(&a).unwrap(), parse(&b).unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
|
||||
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
|
||||
// string is plainly Shift-JIS, so the file is not UTF-8.
|
||||
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
|
||||
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
|
||||
assert_eq!(t[3].str(), "図面");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
|
||||
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
|
||||
assert_eq!(t[2].str(), "図面");
|
||||
assert_eq!(t[3].str(), "ñ");
|
||||
assert_eq!(t[4].str(), "plain");
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
|
||||
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
|
||||
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
|
||||
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
|
||||
//! Pending writer regressions extend the chunk-size finding in issue #1025.
|
||||
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
|
||||
|
||||
fn payload(length: usize) -> Vec<u8> {
|
||||
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
|
||||
}
|
||||
|
||||
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
|
||||
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
|
||||
}
|
||||
|
||||
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_ascii_as(&input(code, bytes), utf8);
|
||||
let lines: Vec<_> = wire.lines().collect();
|
||||
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
|
||||
assert_eq!(following[0].trim(), "70");
|
||||
assert_eq!(following[1], "-7");
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let (records, remainder) = chunks.as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "incomplete group/value pair");
|
||||
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
for record in records {
|
||||
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
|
||||
let text = record[1];
|
||||
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
|
||||
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
|
||||
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "{context}: partial byte");
|
||||
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_binary(&input(code, bytes), utf8);
|
||||
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
while !chunks.is_empty() {
|
||||
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
|
||||
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
|
||||
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
|
||||
let (chunk, next) = rest.split_at(usize::from(length));
|
||||
back.extend_from_slice(chunk);
|
||||
chunks = next;
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = payload(length);
|
||||
check_ascii(code, &bytes, utf8);
|
||||
check_binary(code, &bytes, utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reader_tolerates_the_full_length_byte_domain() {
|
||||
// This is deliberately permissive input, not an assertion that oversized chunks conform
|
||||
// or that the writer should produce them. The following record catches framing mistakes.
|
||||
for code in (310_u16..=319).chain([1004]) {
|
||||
for length in 0..=u8::MAX {
|
||||
let bytes = payload(usize::from(length));
|
||||
let mut wire = SENTINEL.to_vec();
|
||||
wire.extend_from_slice(&code.to_le_bytes());
|
||||
wire.push(length);
|
||||
wire.extend_from_slice(&bytes);
|
||||
wire.extend_from_slice(FOLLOWING_BINARY);
|
||||
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
|
||||
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_ascii(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
|
||||
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_binary(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Deterministic tag preservation through the public byte API, in both encodings and every
|
||||
//! supported version. Inputs have the group code's type and representable values; lossy
|
||||
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn check(case: &str, body: &[Tag]) {
|
||||
for version in Version::ALL {
|
||||
let mut original = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, version.code()),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, "ANSI_1252"),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
];
|
||||
original.extend_from_slice(body);
|
||||
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
for binary in [false, true] {
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = if binary { write_binary(¤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 }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-constraints = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
|
||||
@@ -16,10 +16,31 @@ use std::fs::{File, OpenOptions, Permissions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Locks held from destination metadata inspection through rename, so concurrent writes cannot
|
||||
/// combine permissions from one version of a path with the group of another.
|
||||
fn path_lock(path: &Path) -> Arc<Mutex<()>> {
|
||||
static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
|
||||
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
|
||||
let key = match (absolute.parent(), absolute.file_name()) {
|
||||
(Some(parent), Some(name)) => std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()).join(name),
|
||||
_ => absolute,
|
||||
};
|
||||
let mut locks = LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())).lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
locks.retain(|_, lock| lock.strong_count() > 0);
|
||||
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
|
||||
lock
|
||||
} else {
|
||||
let lock = Arc::new(Mutex::new(()));
|
||||
locks.insert(key, Arc::downgrade(&lock));
|
||||
lock
|
||||
}
|
||||
}
|
||||
|
||||
/// How many fresh temporary names to try before giving up (each collision is another local race).
|
||||
const MAX_TRIES: u32 = 32;
|
||||
|
||||
@@ -33,13 +54,15 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
// A symlinked destination is resolved so the link stays a link and its target is replaced.
|
||||
let resolved = resolve_symlink(path)?;
|
||||
let path = resolved.as_deref().unwrap_or(path);
|
||||
let lock = path_lock(path);
|
||||
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let permissions = match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
Ok(_) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
write_atomic_with(path, permissions, |file| file.write_all(bytes))
|
||||
write_atomic_with_locked(path, permissions, |file| file.write_all(bytes))
|
||||
}
|
||||
|
||||
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
|
||||
@@ -67,10 +90,30 @@ pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()>
|
||||
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
|
||||
/// at `path` is replaced by the new file, never written through.
|
||||
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let lock = path_lock(path);
|
||||
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
write_atomic_with_locked(path, permissions, fill)
|
||||
}
|
||||
|
||||
/// [`write_atomic_with`] with permissions derived under the same destination lock as the write.
|
||||
pub fn write_atomic_with_permissions(
|
||||
path: &Path,
|
||||
permissions: impl FnOnce() -> io::Result<Option<Permissions>>,
|
||||
fill: impl FnOnce(&mut File) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let lock = path_lock(path);
|
||||
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let permissions = permissions()?;
|
||||
write_atomic_with_locked(path, permissions, fill)
|
||||
}
|
||||
|
||||
fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
|
||||
let write = (|| {
|
||||
if let Some(permissions) = permissions {
|
||||
#[cfg(unix)]
|
||||
let permissions = keep_group(&file, path, permissions);
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
fill(&mut file)?;
|
||||
@@ -90,6 +133,20 @@ pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: im
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
|
||||
/// if the file is still in the old file's group, so the group is restored first. When it can't be
|
||||
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
|
||||
#[cfg(unix)]
|
||||
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
|
||||
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
|
||||
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
|
||||
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
|
||||
return permissions;
|
||||
}
|
||||
Permissions::from_mode(permissions.mode() & !0o070)
|
||||
}
|
||||
|
||||
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
|
||||
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
|
||||
/// refuse to sync a directory, and the file itself is already on disk.
|
||||
@@ -231,6 +288,33 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
|
||||
/// says) together with its mode, so the group bits never name a different group.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("out.bin");
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
|
||||
let own = std::fs::metadata(&dest).unwrap().gid();
|
||||
let other = std::process::Command::new("id")
|
||||
.arg("-G")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
|
||||
if let Some(gid) = other {
|
||||
chown(&dest, None, Some(gid)).unwrap();
|
||||
}
|
||||
let want = std::fs::metadata(&dest).unwrap();
|
||||
write_atomic(&dest, b"new").unwrap();
|
||||
let got = std::fs::metadata(&dest).unwrap();
|
||||
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn does_not_follow_a_planted_temp_symlink() {
|
||||
|
||||
@@ -144,10 +144,10 @@ impl BlockEdit {
|
||||
}
|
||||
let host = &self.host.doc;
|
||||
let mut d = editor.clone();
|
||||
// When the block is saved under another name, the edited block keeps the drawing's
|
||||
// description and block `name` gets the description changed in the editor.
|
||||
// When saving a copy, the source keeps its last saved definition. The editor's own
|
||||
// table entry can still be the one from before BSAVE; the copy gets the edited data.
|
||||
if !name.eq_ignore_ascii_case(&self.name) {
|
||||
keep_description(&mut d, host, &self.name);
|
||||
restore_saved_definition(&mut d, host, &self.name);
|
||||
}
|
||||
d.model = host.model.clone();
|
||||
d.layouts = host.layouts.clone();
|
||||
@@ -176,16 +176,13 @@ impl BlockEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
|
||||
/// drop the empty definition a description gives a block the editor started and never saved.
|
||||
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
|
||||
/// Without a saved source, drop the empty definition an editor description may have created.
|
||||
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||
match host.block(name) {
|
||||
Some(h) => {
|
||||
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
&& b.description != h.description
|
||||
{
|
||||
Arc::make_mut(b).description = h.description.clone();
|
||||
}
|
||||
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
|
||||
d.blocks.insert(key, h.clone());
|
||||
}
|
||||
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
|
||||
}
|
||||
@@ -302,7 +299,7 @@ impl DocState {
|
||||
let was = be.name.clone();
|
||||
let reconcile = |doc: &mut Arc<Drawing>| {
|
||||
let editor = Arc::make_mut(doc);
|
||||
keep_description(editor, &d, &was);
|
||||
restore_saved_definition(editor, &d, &was);
|
||||
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
|
||||
editor.blocks.insert(name.clone(), b.clone());
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +188,9 @@ fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)
|
||||
let mut best: Option<((u32, u32, u32), f64)> = None;
|
||||
// Each block's own extents, measured once.
|
||||
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
|
||||
let block_for = p.block_lookup();
|
||||
for it in p.items()? {
|
||||
let name = p.block_for(it.i, it.j, it.level);
|
||||
let name = block_for(it.i, it.j, it.level);
|
||||
let (base, local) = match extents.get(name) {
|
||||
Some(x) => *x,
|
||||
None => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
.noundo(),
|
||||
CommandSpec::new("-xref", "External References", run_dash_xref)
|
||||
.alias(&["-xr"])
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
|
||||
CommandSpec::new("xbind", "Bind...", run_xbind)
|
||||
.menu(&["Modify", "Object", "External Reference", "Bind..."])
|
||||
@@ -201,6 +201,46 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The xrefs a JSON call names (`names`, default all); an error when none match.
|
||||
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
|
||||
if p.get("exact").and_then(Value::as_bool) == Some(true) {
|
||||
let lits: Vec<&str> = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
|
||||
Some(Value::String(t)) => vec![t.as_str()],
|
||||
_ => str_param(p, "name").into_iter().collect(),
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let mut names = Vec::new();
|
||||
let mut selected = std::collections::HashSet::new();
|
||||
// Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together.
|
||||
let mut folded = std::collections::HashMap::new();
|
||||
for literal in &lits {
|
||||
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
|
||||
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
|
||||
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
|
||||
}
|
||||
if selected.insert(block.name.clone()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let normalized = literal.to_ascii_lowercase();
|
||||
let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized);
|
||||
if let Some(block) = matches.next() {
|
||||
if matches.next().is_some() {
|
||||
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
|
||||
}
|
||||
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
|
||||
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
|
||||
}
|
||||
if selected.insert(block.name.clone()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
|
||||
}
|
||||
return Ok(names);
|
||||
}
|
||||
let pattern = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
|
||||
Some(Value::String(t)) => t.clone(),
|
||||
@@ -223,6 +263,19 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only
|
||||
/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the
|
||||
/// command must refuse before it changes anything.
|
||||
fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> {
|
||||
let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() };
|
||||
for n in names {
|
||||
if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
|
||||
let host = host(s);
|
||||
@@ -252,6 +305,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
"detach" | "d" => {
|
||||
let names = named(s, "-xref", p)?;
|
||||
refuse_case_clash(s.doc()?, "-xref", &names, true)?;
|
||||
let mut erased = 0;
|
||||
for n in &names {
|
||||
erased += load::detach(s.doc_mut()?, n);
|
||||
@@ -276,6 +330,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"path" | "p" => {
|
||||
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
|
||||
let names = named(s, "-xref", p)?;
|
||||
refuse_case_clash(s.doc()?, "-xref", &names, false)?;
|
||||
let d = s.doc_mut()?;
|
||||
for n in &names {
|
||||
if let Some(b) = d.blocks.get_mut(n) {
|
||||
|
||||
@@ -313,7 +313,23 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool
|
||||
/// [`load`] the xrefs `names` within one set of limits.
|
||||
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
|
||||
let mut cx = Ctx::new(host);
|
||||
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
|
||||
// Names are case-insensitive, so a name that differs only by case from an earlier one is the same xref.
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
names
|
||||
.iter()
|
||||
.flat_map(|n| match seen.entry(n.to_ascii_lowercase()) {
|
||||
std::collections::hash_map::Entry::Vacant(v) => {
|
||||
v.insert(n.as_str());
|
||||
load_with(d, n, host, chosen, &mut cx)
|
||||
}
|
||||
// Reported, not silent: the skipped xref keeps its status and is not loaded here.
|
||||
std::collections::hash_map::Entry::Occupied(o) => vec![Outcome {
|
||||
name: String::new(),
|
||||
status: XrefStatus::Unresolved,
|
||||
detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()),
|
||||
}],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
|
||||
@@ -453,14 +453,32 @@ impl Chain {
|
||||
}
|
||||
/// Point and unit tangent at arc length `d` from the start (clamped).
|
||||
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let mut acc = 0.0;
|
||||
for s in &self.segs {
|
||||
let l = s.len();
|
||||
if d <= acc + l + 1e-12 && l > 0.0 {
|
||||
let t = ((d - acc) / l).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
acc += l;
|
||||
self.at_length_in(&self.end_lengths(), d)
|
||||
}
|
||||
/// The arc length from the chain's start to the end of each segment, for [`Self::at_length_in`].
|
||||
pub fn end_lengths(&self) -> Vec<f64> {
|
||||
self.segs
|
||||
.iter()
|
||||
.scan(0.0, |acc, s| {
|
||||
*acc += s.len();
|
||||
Some(*acc)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// [`Self::at_length`] with the segment end lengths computed once (`ends` from
|
||||
/// [`Self::end_lengths`]), so many samples along one chain each cost a binary search.
|
||||
pub fn at_length_in(&self, ends: &[f64], d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let first = ends.partition_point(|e| d > e + 1e-12);
|
||||
// A segment of no length can't hold the point; the next one that has length does.
|
||||
// `ends` never decreases, so the first segment past `first` with length is found by search
|
||||
// (a run of zero-length segments can be long) instead of a scan.
|
||||
let base = first.checked_sub(1).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let i = first + ends.get(first..).unwrap_or_default().partition_point(|e| *e <= base);
|
||||
let i = (i < self.segs.len()).then_some(i);
|
||||
if let Some(s) = i.and_then(|i| self.segs.get(i)) {
|
||||
let start = i.and_then(|i| i.checked_sub(1)).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let t = ((d - start) / s.len()).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
|
||||
}
|
||||
@@ -640,6 +658,34 @@ pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The indexed lookup gives the point and tangent a scan from the start gives, also past the
|
||||
/// ends and over a segment of no length.
|
||||
#[test]
|
||||
fn at_length_in_matches_a_scan() {
|
||||
let l = |a: (f64, f64), b: (f64, f64)| Segment::Line(Line::new(Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)));
|
||||
let c = Chain {
|
||||
segs: vec![l((0.0, 0.0), (0.0, 0.0)), l((0.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 3.0))],
|
||||
closed: false,
|
||||
};
|
||||
let ends = c.end_lengths();
|
||||
for d in [-1.0, 0.0, 1.5, 4.0, 4.0 + 1e-13, 5.0, 7.0, 9.0] {
|
||||
// The scan this replaced.
|
||||
let mut acc = 0.0;
|
||||
let mut want = c.segs.last().map(|s| (s.end(), s.tangent(1.0)));
|
||||
for s in &c.segs {
|
||||
let len = s.len();
|
||||
if d <= acc + len + 1e-12 && len > 0.0 {
|
||||
let t = ((d - acc) / len).clamp(0.0, 1.0);
|
||||
want = Some((s.at(t), s.tangent(t)));
|
||||
break;
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
assert_eq!(c.at_length_in(&ends, d), want, "d = {d}");
|
||||
assert_eq!(c.at_length(d), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_builders() {
|
||||
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
|
||||
@@ -36,16 +36,18 @@ fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
|
||||
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let range = if c.closed { 0..n } else { 1..n };
|
||||
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
|
||||
let ends = c.end_lengths();
|
||||
range.filter_map(|i| c.at_length_in(&ends, l * i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let ends = c.end_lengths();
|
||||
let mut out = Vec::new();
|
||||
let mut k = 1usize;
|
||||
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
|
||||
let d = k as f64 * step;
|
||||
out.extend(c.at_length(d));
|
||||
out.extend(c.at_length_in(&ends, d));
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
|
||||
@@ -58,8 +58,9 @@ pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, re
|
||||
closed: true,
|
||||
};
|
||||
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
|
||||
let ends = chain.end_lengths();
|
||||
for j in 0..k {
|
||||
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
|
||||
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
|
||||
}
|
||||
}
|
||||
let b = if reverse { -0.5 } else { 0.5 };
|
||||
|
||||
+101
-17
@@ -8,7 +8,7 @@ mod template;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cadcraft_doc::{Drawing, Format};
|
||||
pub use cadcraft_dxf::Version;
|
||||
pub use craft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
|
||||
@@ -53,9 +53,9 @@ pub fn io() -> Option<&'static IoHooks> {
|
||||
/// (installed by the app with the DXF reader and writer, a lower layer).
|
||||
pub struct EntityCodec {
|
||||
/// The entity's type name and the groups of its record, without its `0` group.
|
||||
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
|
||||
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<craft_dxf::Tag>),
|
||||
/// An entity from a type name and groups.
|
||||
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
pub entity: fn(&str, &[craft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
}
|
||||
|
||||
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
|
||||
@@ -340,7 +340,7 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
|
||||
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
|
||||
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
}
|
||||
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
Ok(ReadFile { drawing, binary_dxf: craft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
}
|
||||
|
||||
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
|
||||
@@ -503,7 +503,7 @@ pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
|
||||
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
|
||||
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg || cadcraft_dxf::is_binary(bytes) {
|
||||
if dwg || craft_dxf::is_binary(bytes) {
|
||||
return true;
|
||||
}
|
||||
// ASCII DXF: optional comments (999), then group 0 SECTION.
|
||||
@@ -533,7 +533,7 @@ pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
|
||||
// Too short for any DWG's header: cut off, not unknown.
|
||||
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
|
||||
}
|
||||
if cadcraft_dxf::is_binary(head) {
|
||||
if craft_dxf::is_binary(head) {
|
||||
return Some(tail.ends_with(b"EOF\0"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(tail);
|
||||
@@ -625,6 +625,40 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
|
||||
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
|
||||
/// differs from the drawing's group, its group bits are dropped and its other bits are capped by
|
||||
/// the drawing's group bits: a member of the drawing's group must not gain access through `other`.
|
||||
#[cfg(unix)]
|
||||
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
|
||||
let mut mode = src_mode & 0o777;
|
||||
if let Some((old_mode, old_gid)) = old {
|
||||
mode &= old_mode;
|
||||
if old_gid != src_gid {
|
||||
mode = without_group(src_mode, mode);
|
||||
}
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// `mode` for a backup that is not in the drawing's group: no group bits, and `other` capped by the
|
||||
/// drawing's group bits (`src_mode`).
|
||||
#[cfg(unix)]
|
||||
fn without_group(src_mode: u32, mode: u32) -> u32 {
|
||||
(mode & !0o070) & (!0o007 | ((src_mode & 0o070) >> 3))
|
||||
}
|
||||
|
||||
/// A first backup is created in the process's (or folder's) group, not the drawing's: move it to the
|
||||
/// drawing's group before any data is written, else drop its group bits ([`without_group`]).
|
||||
#[cfg(unix)]
|
||||
fn adopt_source_group(out: &std::fs::File, src: &std::fs::Metadata) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
|
||||
if out.metadata()?.gid() == src.gid() || fchown(out, None, Some(src.gid())).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
out.set_permissions(std::fs::Permissions::from_mode(without_group(src.mode(), src.mode() & 0o777)))
|
||||
}
|
||||
|
||||
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
|
||||
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
|
||||
/// to keep when there is no file yet. A file that doesn't look like a drawing
|
||||
@@ -648,19 +682,23 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
|
||||
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
|
||||
// written through.
|
||||
#[cfg(unix)]
|
||||
let permissions = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut mode = src.metadata()?.permissions().mode() & 0o777;
|
||||
if let Ok(m) = std::fs::symlink_metadata(&bak)
|
||||
&& m.is_file()
|
||||
{
|
||||
mode &= m.permissions().mode();
|
||||
}
|
||||
Some(std::fs::Permissions::from_mode(mode))
|
||||
let source_metadata = src.metadata()?;
|
||||
#[cfg(unix)]
|
||||
let first_backup = std::cell::Cell::new(false);
|
||||
#[cfg(unix)]
|
||||
let permissions = || {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
|
||||
first_backup.set(old.is_none());
|
||||
Ok(Some(std::fs::Permissions::from_mode(backup_mode(source_metadata.mode(), source_metadata.gid(), old.map(|m| (m.mode(), m.gid()))))))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let permissions = None;
|
||||
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
|
||||
let permissions = || Ok(None);
|
||||
crate::atomic_write::write_atomic_with_permissions(&bak, permissions, |out| {
|
||||
#[cfg(unix)]
|
||||
if first_backup.get() {
|
||||
adopt_source_group(out, &source_metadata)?;
|
||||
}
|
||||
std::io::Write::write_all(out, &head)?;
|
||||
std::io::copy(&mut src, out).map(|_| ())
|
||||
})
|
||||
@@ -1031,6 +1069,52 @@ mod tests {
|
||||
use crate::Session;
|
||||
use crate::temp_dir::TempDir;
|
||||
|
||||
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
|
||||
/// it would keep the old `.bak`'s group while the drawing is in another.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backup_mode_drops_group_bits_for_a_different_group() {
|
||||
use super::backup_mode;
|
||||
assert_eq!(backup_mode(0o640, 10, None), 0o640);
|
||||
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
|
||||
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
|
||||
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
|
||||
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
|
||||
}
|
||||
|
||||
/// With no `.bak` yet, the first backup takes the drawing's group (when the user is in another
|
||||
/// group, as `id -G` says), never the group the new file would be created in.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn first_backup_takes_the_drawings_group() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
|
||||
let dir = TempDir::new("first-bak-grp");
|
||||
let src = dir.join("a.dxf");
|
||||
std::fs::write(&src, "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n").unwrap();
|
||||
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
let own = std::fs::metadata(&src).unwrap().gid();
|
||||
let other = std::process::Command::new("id")
|
||||
.arg("-G")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
|
||||
if let Some(gid) = other {
|
||||
chown(&src, None, Some(gid)).unwrap();
|
||||
}
|
||||
super::backup(&src).unwrap();
|
||||
let (s, b) = (std::fs::metadata(&src).unwrap(), std::fs::metadata(super::backup_path(&src)).unwrap());
|
||||
assert_eq!((b.gid(), b.mode() & 0o777), (s.gid(), 0o640));
|
||||
}
|
||||
|
||||
/// The fail-safe when the group cannot be adopted: no group bits, `other` capped by the group's.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn without_group_drops_group_bits_and_caps_other() {
|
||||
assert_eq!(super::without_group(0o640, 0o640), 0o600);
|
||||
assert_eq!(super::without_group(0o604, 0o604), 0o600);
|
||||
assert_eq!(super::without_group(0o644, 0o644), 0o604);
|
||||
}
|
||||
|
||||
fn active_title(s: &Session) -> String {
|
||||
s.state().unwrap().title.clone()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,11 +26,30 @@ const MAX_PURGE_PASSES: usize = 64;
|
||||
|
||||
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
|
||||
pub(super) fn default_path(s: &Session, layout: &str) -> String {
|
||||
let file = format!("{layout}.dwt");
|
||||
match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
|
||||
None => file,
|
||||
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
|
||||
// beside the drawing, so those become `_`.
|
||||
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
|
||||
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
|
||||
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
|
||||
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
|
||||
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
|
||||
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file),
|
||||
None => std::path::PathBuf::from(file),
|
||||
};
|
||||
if !candidate.exists() {
|
||||
return candidate.to_string_lossy().into_owned();
|
||||
}
|
||||
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
|
||||
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
|
||||
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
|
||||
// The suffix range never ends in practice; the fallback only keeps the code panic-free.
|
||||
let alternative = (2u64..)
|
||||
.map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}")))
|
||||
.find(|alternative| !alternative.exists())
|
||||
.unwrap_or(candidate.clone());
|
||||
alternative.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
|
||||
@@ -133,3 +152,32 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod default_path_tests {
|
||||
use super::*;
|
||||
|
||||
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
|
||||
#[test]
|
||||
fn layout_name_is_one_file_name() {
|
||||
let s = Session::empty();
|
||||
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
|
||||
let p = default_path(&s, name);
|
||||
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
|
||||
assert!(p.ends_with(".dwt"));
|
||||
}
|
||||
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
|
||||
}
|
||||
|
||||
/// Windows device names are prefixed so the suggestion is never a device.
|
||||
#[test]
|
||||
fn reserved_device_names_are_prefixed() {
|
||||
let s = Session::empty();
|
||||
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
|
||||
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
|
||||
}
|
||||
for name in ["COM0", "CONSOLE", "COM10"] {
|
||||
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -34,10 +34,12 @@ pub(crate) fn arraypath(
|
||||
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
|
||||
};
|
||||
crate::cmd::array::check_size(s, n as u64, objs.len())?;
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
// Segment end lengths once: each sample is then a binary search, not a scan from the start.
|
||||
let ends = c.end_lengths();
|
||||
let Some((p0, t0)) = c.at_length_in(&ends, 0.0) else { return Ok(Vec::new()) };
|
||||
let mut mats = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
|
||||
let Some((p, t)) = c.at_length_in(&ends, step * i as f64) else { continue };
|
||||
let mut m = Mat3::translate(p - p0);
|
||||
if align {
|
||||
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
|
||||
|
||||
@@ -108,8 +108,9 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
|
||||
|
||||
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
|
||||
pub fn wildcard(pat: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
|
||||
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
|
||||
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
|
||||
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
|
||||
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
|
||||
// Iterative matcher with single back-track point (linear-ish, no recursion).
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
|
||||
@@ -123,6 +123,12 @@ pub struct LispState {
|
||||
acad_loaded: bool,
|
||||
/// Memory caps (lowered by tests).
|
||||
pub(crate) limits: Limits,
|
||||
/// Interpreter steps all synchronous callbacks have run, nested ones included. A callback's
|
||||
/// allowance counts what ran since it started, so the callbacks it calls spend its steps.
|
||||
pub(crate) callback_used: u64,
|
||||
/// The `callback_used` value the callback running now (and so every one it calls) must not
|
||||
/// pass: the lowest of its own and its callers' allowances.
|
||||
pub(crate) callback_ceiling: Option<u64>,
|
||||
}
|
||||
|
||||
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
|
||||
|
||||
@@ -27,8 +27,14 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
|
||||
if !writing {
|
||||
match read_text_file(&path) {
|
||||
Some(t) => {
|
||||
charge_bytes(t.len())?;
|
||||
f.lines = t.lines().map(str::to_string).collect();
|
||||
// Each line has a String header and a separately allocated buffer. Charge a
|
||||
// conservative 64 bytes per line for both, including the allocator's minimum
|
||||
// allocation size, before collecting so many short lines cannot evade the budget.
|
||||
let count = t.lines().count();
|
||||
charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?;
|
||||
// Exact capacity: collecting would grow by doubling and hold unbilled slack.
|
||||
f.lines = std::collections::VecDeque::with_capacity(count);
|
||||
f.lines.extend(t.lines().map(str::to_string));
|
||||
}
|
||||
None => return Ok(Flow::Value(Value::Nil)),
|
||||
}
|
||||
@@ -100,8 +106,19 @@ fn can_write(_path: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_text_file(path: &str, text: &str, append: bool) {
|
||||
use std::io::Write;
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
|
||||
if let Ok(mut f) = f {
|
||||
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
|
||||
// instead of stalling the interpreter; only a regular file is then truncated and written.
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.create(true).write(true).append(append);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
if let Ok(mut f) = o.open(path)
|
||||
&& f.metadata().is_ok_and(|m| m.is_file())
|
||||
&& (append || f.set_len(0).is_ok())
|
||||
{
|
||||
let _ = f.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -118,3 +135,29 @@ fn file_exists(path: &str) -> bool {
|
||||
fn file_exists(_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
|
||||
#[test]
|
||||
fn write_to_a_fifo_without_a_reader_does_not_block() {
|
||||
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fifo = dir.join("pipe");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
|
||||
let started = std::time::Instant::now();
|
||||
write_text_file(fifo.to_str().unwrap(), "x", false);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
let f = dir.join("f.txt");
|
||||
std::fs::write(&f, "long old text").unwrap();
|
||||
write_text_file(f.to_str().unwrap(), "new", false);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
|
||||
write_text_file(f.to_str().unwrap(), "+", true);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::args::{R, arity, int, list_arg, string};
|
||||
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
|
||||
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
|
||||
|
||||
// ---------------- strings ----------------
|
||||
|
||||
@@ -114,7 +114,13 @@ pub(super) fn read_fn(a: &[Value]) -> R {
|
||||
arity(a, 0, 1)?;
|
||||
match a.first() {
|
||||
None | Some(Value::Nil) => Ok(Value::Nil),
|
||||
Some(v) => read::read_one(&string(v)?),
|
||||
Some(v) => {
|
||||
// The strings in the list it makes are new, each as long as its text at most: the
|
||||
// text is charged, though a list result isn't inspected by `charge_result`.
|
||||
let text = string(v)?;
|
||||
charge_bytes(text.len())?;
|
||||
read::read_one(&text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -179,7 +179,12 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::Int(i) => serde_json::json!(i),
|
||||
Value::Real(r) => serde_json::json!(r),
|
||||
Value::Str(s) => serde_json::json!(&**s),
|
||||
Value::Str(s) => {
|
||||
// A string is copied whole each time it occurs: it spends the allowance by its length
|
||||
// (one unit per 16 bytes, so about 32 MB in all), shared or not.
|
||||
*budget = budget.checked_sub(s.len() as u64 / 16)?;
|
||||
serde_json::json!(&**s)
|
||||
}
|
||||
Value::Cons(_) => {
|
||||
let mut arr = Vec::new();
|
||||
for x in v.iter() {
|
||||
@@ -248,20 +253,20 @@ fn convert_angles(kind: &str, tags: &mut [Tag], f: fn(f64) -> f64) {
|
||||
continue;
|
||||
}
|
||||
let v = match t.value {
|
||||
cadcraft_dxf::Value::Real(r) => r,
|
||||
cadcraft_dxf::Value::Int(i) => i as f64,
|
||||
craft_dxf::Value::Real(r) => r,
|
||||
craft_dxf::Value::Int(i) => i as f64,
|
||||
_ => continue,
|
||||
};
|
||||
t.value = cadcraft_dxf::Value::Real(f(v));
|
||||
t.value = craft_dxf::Value::Real(f(v));
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_value(t: &Tag) -> Value {
|
||||
match &t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
|
||||
cadcraft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
cadcraft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
cadcraft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => Value::str(s),
|
||||
craft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
craft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
craft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,12 +301,16 @@ fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most string bytes an entity list may hold (counted per occurrence, shared or not).
|
||||
const MAX_GROUP_TEXT: usize = 32 << 20;
|
||||
|
||||
/// An association list back to DXF groups (points split into x, y, z groups). The type
|
||||
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
|
||||
/// left out: they aren't the entity's to set.
|
||||
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
let mut kind = None;
|
||||
let mut tags = Vec::new();
|
||||
let mut text_left = MAX_GROUP_TEXT;
|
||||
for item in list.iter() {
|
||||
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
|
||||
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
|
||||
@@ -323,7 +332,11 @@ fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
_ => tags.push(match &v {
|
||||
Value::Int(i) => Tag::i(code, i64::from(*i)),
|
||||
Value::Real(r) => Tag::f(code, *r),
|
||||
Value::Str(s) => Tag::s(code, &**s),
|
||||
Value::Str(s) => {
|
||||
// Each occurrence of a shared string becomes its own copy in the tag.
|
||||
text_left = text_left.checked_sub(s.len()).ok_or_else(|| LispError::new("out of memory: the entity list holds too much text"))?;
|
||||
Tag::s(code, &**s)
|
||||
}
|
||||
Value::Ename(h) => Tag::s(code, h.hex()),
|
||||
other => return Err(LispError::bad_arg("DXF group value", other)),
|
||||
}),
|
||||
@@ -1125,3 +1138,27 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
/// One 64 KB string occurring many times is charged by its length each time.
|
||||
#[test]
|
||||
fn shared_strings_spend_the_conversion_budgets() {
|
||||
let big = Value::str(&"x".repeat(64 << 10));
|
||||
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
|
||||
let list = |n: usize| {
|
||||
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
|
||||
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
|
||||
Value::list(items)
|
||||
};
|
||||
assert!(list_to_groups(&list(600)).is_err());
|
||||
assert!(list_to_groups(&list(100)).is_ok());
|
||||
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,10 @@ impl Machine {
|
||||
if n > budget {
|
||||
return Outcome::Wait(Need::Yield);
|
||||
}
|
||||
// Charge a callback's step as it runs, so callbacks it calls see what it already spent.
|
||||
if ctx.depth > 0 {
|
||||
ctx.s.lisp.callback_used = ctx.s.lisp.callback_used.saturating_add(1);
|
||||
}
|
||||
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
|
||||
State::Eval(expr) => self.eval(ctx, expr),
|
||||
State::Call(f, args) => self.call(ctx, f, args),
|
||||
@@ -646,29 +650,44 @@ impl Ctx<'_> {
|
||||
super::value::charge_result(&v)?;
|
||||
return Ok(v);
|
||||
}
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let start = self.s.lisp.callback_used;
|
||||
// This callback may run until `callback_used` reaches `ceiling`: `cap` steps from now, or
|
||||
// sooner where a caller's allowance ends, so callbacks nested in callbacks share one
|
||||
// allowance instead of each taking a fresh one (an exponential tree otherwise).
|
||||
let outer = self.s.lisp.callback_ceiling;
|
||||
let ceiling = start.saturating_add(cap).min(outer.unwrap_or(u64::MAX));
|
||||
self.s.lisp.callback_ceiling = Some(ceiling);
|
||||
let r = self.drive_callback(f, args, ceiling);
|
||||
self.s.lisp.callback_ceiling = outer;
|
||||
r
|
||||
}
|
||||
|
||||
/// Run callback `f` in slices until it finishes or `callback_used` reaches `ceiling`.
|
||||
fn drive_callback(&mut self, f: &Value, args: Vec<Value>, ceiling: u64) -> Result<Value, LispError> {
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let mut m = Machine::calling(f.clone(), args);
|
||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
|
||||
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
|
||||
// (the step budget ran out) is retried until the ceiling, so a constant-memory infinite
|
||||
// loop inside a comparator or predicate terminates with an error rather than hanging the
|
||||
// thread and bypassing the blocking execution cap (CWE-400).
|
||||
let mut spent: u64 = 0;
|
||||
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
|
||||
// one calls are counted in `callback_used` too.
|
||||
loop {
|
||||
let remaining = cap.saturating_sub(spent);
|
||||
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
|
||||
if remaining == 0 {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
let slice = remaining.min(SYNC_SLICE);
|
||||
match m.run(&mut sub, slice) {
|
||||
let outcome = m.run(&mut sub, slice);
|
||||
match outcome {
|
||||
// The steps were charged as they ran; finishing past the shared allowance is a failure too.
|
||||
Outcome::Done(_) if sub.s.lisp.callback_used > ceiling => {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
Outcome::Done(v) => return Ok(v),
|
||||
Outcome::Error(e) => return Err(e),
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
spent = spent.saturating_add(slice);
|
||||
if spent >= cap {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
}
|
||||
Outcome::Wait(Need::Yield) => {}
|
||||
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,35 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
|
||||
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
|
||||
}
|
||||
|
||||
/// Callbacks that call callbacks share one allowance: a branching routine can't multiply it.
|
||||
#[test]
|
||||
fn nested_callbacks_share_one_step_allowance() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000_000;
|
||||
// 4^14 leaf calls if every callback took a fresh allowance; the whole tree gets one.
|
||||
let started = std::time::Instant::now();
|
||||
let e = eval(&mut s, "(defun f (n) (if (> n 0) (vl-every '(lambda (x) (f (1- n)) t) '(1 2 3 4)) t)) (f 14)").unwrap_err();
|
||||
assert!(e.contains("callback stopped after 1000000 steps"), "{e}");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(60));
|
||||
// The next run starts afresh.
|
||||
assert_eq!(ok(&mut s, "(f 1)"), "T");
|
||||
}
|
||||
|
||||
/// An outer callback's own steps count against the allowance before the callbacks it calls run.
|
||||
#[test]
|
||||
fn an_outer_callbacks_steps_are_charged_before_a_nested_one() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000;
|
||||
ok(&mut s, "(setq inner-steps 0)");
|
||||
// The outer callback spends ~600 steps, then a nested one tries to spend ~600 more.
|
||||
let r = eval(
|
||||
&mut s,
|
||||
"(vl-remove-if '(lambda (x) (repeat 150 (setq a 1)) (vl-remove-if '(lambda (y) (repeat 150 (setq inner-steps (1+ inner-steps)))) '(1)) nil) '(1))",
|
||||
);
|
||||
assert_eq!(r, Err("callback stopped after 1000 steps".into()));
|
||||
assert!(ok(&mut s, "inner-steps").parse::<i64>().unwrap() < 150);
|
||||
}
|
||||
|
||||
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
|
||||
#[test]
|
||||
fn setvar_rejects_an_oversized_shared_structure() {
|
||||
@@ -987,6 +1016,9 @@ fn strings_are_capped() {
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// READ makes new strings inside the list it returns: they count, though the result is a list.
|
||||
let e = eval(&mut s, "(setq a \"(\\\"0123456789\\\")\") (repeat 100000 (setq b (read a)))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// The budget is per run: the next one starts afresh.
|
||||
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
|
||||
}
|
||||
|
||||
@@ -346,7 +346,11 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
|
||||
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
|
||||
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
|
||||
let mut budget: u64 = EQUAL_BUDGET;
|
||||
equal_within(a, b, fuzz, EQUAL_BUDGET)
|
||||
}
|
||||
|
||||
/// [`equal`] with its work allowance (node visits plus string bytes / 64) given.
|
||||
fn equal_within(a: &Value, b: &Value, fuzz: f64, mut budget: u64) -> bool {
|
||||
equal_depth(a, b, fuzz, 0, &mut budget)
|
||||
}
|
||||
|
||||
@@ -373,7 +377,19 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
|
||||
a = na;
|
||||
b = nb;
|
||||
}
|
||||
_ => return atom_equal(&a, &b, fuzz),
|
||||
_ => {
|
||||
// Comparing two strings costs their length, not one visit: a shared list of long
|
||||
// equal strings must spend the allowance by the bytes it compares (one unit per 64).
|
||||
if let (Value::Str(x), Value::Str(y)) = (&a, &b) {
|
||||
let cost = (x.len().min(y.len()) / 64) as u64;
|
||||
if cost >= *budget {
|
||||
*budget = 0;
|
||||
return false;
|
||||
}
|
||||
*budget -= cost;
|
||||
}
|
||||
return atom_equal(&a, &b, fuzz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,6 +556,20 @@ fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `equal` charges the bytes of the strings it compares: a list of long equal strings fits a
|
||||
/// big allowance but not a small one, though it has few nodes.
|
||||
#[test]
|
||||
fn equal_spends_its_allowance_on_string_bytes() {
|
||||
let long = Value::str(&"x".repeat(6_400));
|
||||
let other = Value::str(&"x".repeat(6_400));
|
||||
let list = |v: &Value| Value::list(vec![v.clone(); 100]);
|
||||
let (a, b) = (list(&long), list(&other));
|
||||
// 100 strings x 100 units each, plus about 100 node visits.
|
||||
assert!(equal_within(&a, &b, 0.0, 20_000));
|
||||
assert!(!equal_within(&a, &b, 0.0, 5_000));
|
||||
assert!(equal(&a, &b, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reals_print_with_six_significant_digits() {
|
||||
assert_eq!(real_text(1.0), "1.0");
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Saving an edited block as a copy must retain its source's last saved definition.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
|
||||
/// source block keeps the definition of its own last save.
|
||||
#[test]
|
||||
fn save_as_preserves_the_last_saved_source_definition() {
|
||||
let mut s = Session::new();
|
||||
let mut handles = Vec::new();
|
||||
for y in [5, 9] {
|
||||
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
|
||||
handles.push(line["handles"][0].clone());
|
||||
}
|
||||
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
|
||||
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
|
||||
assert_eq!(saved_source.entities.len(), 3);
|
||||
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
|
||||
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
|
||||
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
|
||||
|
||||
// A later save of Copy must not reintroduce the editor table's older source definition.
|
||||
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
|
||||
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
@@ -23,10 +23,10 @@ fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, V
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let v = match t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
|
||||
cadcraft_dxf::Value::Real(r) => json!(r),
|
||||
cadcraft_dxf::Value::Int(i) => json!(i),
|
||||
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => json!(s),
|
||||
craft_dxf::Value::Real(r) => json!(r),
|
||||
craft_dxf::Value::Int(i) => json!(i),
|
||||
craft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
};
|
||||
(t.code, v)
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -190,6 +190,28 @@ fn reload_unload_path_and_detach() {
|
||||
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
|
||||
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
|
||||
|
||||
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
|
||||
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
|
||||
|
||||
// Two xrefs that differ only by case: an exact selection of both is refused before anything loads,
|
||||
// and reload-all loads one of them once.
|
||||
let mut twin = Block::new("SITE");
|
||||
twin.xref_path = Some("site.dxf".into());
|
||||
s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin));
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err());
|
||||
let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap();
|
||||
assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1));
|
||||
// Detaching one of them, or giving both a new path, is refused before anything changes.
|
||||
assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err());
|
||||
assert!(s.doc().unwrap().block("site").is_some());
|
||||
assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err());
|
||||
for k in ["site", "SITE"] {
|
||||
assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}");
|
||||
}
|
||||
s.doc_mut().unwrap().blocks.remove("SITE");
|
||||
|
||||
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
|
||||
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
|
||||
assert!(s.doc().unwrap().block("site|Tree").is_none());
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
+28
-3
@@ -43,13 +43,16 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
rerun_if_changed(&manifest)?;
|
||||
// Resolve the checkout root once so every manifest file can be confined inside it.
|
||||
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
|
||||
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
|
||||
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
// The line's text is not repeated: it comes from the checkout and goes to the build output.
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
return Err(format!("malformed manifest line {n}"));
|
||||
};
|
||||
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
|
||||
@@ -73,6 +76,28 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most a manifest may hold; the real one is a few KiB.
|
||||
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
|
||||
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
|
||||
use std::io::Read as _;
|
||||
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
|
||||
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
|
||||
return Err(format!("{}: not a regular file", path.display()));
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !file.metadata().is_ok_and(|m| m.is_file()) {
|
||||
return Err(format!("{}: not a regular file", path.display()));
|
||||
}
|
||||
let mut text = String::new();
|
||||
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
||||
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
|
||||
///
|
||||
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
|
||||
|
||||
+10
-1
@@ -184,6 +184,9 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
|
||||
let mut under_start: Option<f64> = None;
|
||||
let mut over_start: Option<f64> = None;
|
||||
let mut gap = stroke::GAP * sc * wf;
|
||||
// Retained fallback-outline points over the line, as the TrueType path caps them: a short string
|
||||
// repeating one detailed glyph would otherwise copy its outline per occurrence.
|
||||
let mut points_left = ttf::MAX_SHAPED_POINTS;
|
||||
// The simple path lays a line out left to right: one asked to run right to left goes through
|
||||
// the bidi path even without right-to-left letters.
|
||||
if (complex::needed(s) || base_rtl == Some(true))
|
||||
@@ -202,7 +205,13 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
|
||||
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
|
||||
Some(g) => {
|
||||
let (y, scale) = stroke_fallback_metrics(&g, c, h);
|
||||
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
|
||||
let contours = match points_left.checked_sub(g.points) {
|
||||
Some(rest) => {
|
||||
points_left = rest;
|
||||
ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear)
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
if !contours.is_empty() {
|
||||
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
|
||||
}
|
||||
|
||||
@@ -153,6 +153,40 @@ pub struct MTextLayout {
|
||||
pub bounds: Bounds2,
|
||||
/// Plain text of each laid-out line.
|
||||
pub lines: Vec<String>,
|
||||
/// Whether the geometry stops short: the layout reached its allowance of retained points
|
||||
/// ([`Allowance`]) and the segments after that are not drawn.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// The points all the segments of one layout may keep together, each counted for both copies the
|
||||
/// layout holds: the allowance one shaped line has (`MAX_SHAPED_POINTS`), so a short repeated detailed glyph over many segments cannot multiply it.
|
||||
pub(crate) struct Allowance(usize);
|
||||
|
||||
impl Allowance {
|
||||
pub(crate) fn new() -> Self {
|
||||
Allowance(crate::ttf::MAX_SHAPED_POINTS)
|
||||
}
|
||||
|
||||
/// Whether any allowance is left, before a segment is shaped.
|
||||
pub(crate) fn left(&self) -> bool {
|
||||
self.0 > 0
|
||||
}
|
||||
|
||||
/// Charge the points of `sh`; false (and nothing left) when it does not fit.
|
||||
pub(crate) fn charge(&mut self, sh: &Shaped) -> bool {
|
||||
// Kept twice: in the piece and again in the layout's flat `strokes`.
|
||||
let n = 2 * (sh.strokes.iter().map(Vec::len).sum::<usize>() + sh.glyphs.iter().flatten().map(Vec::len).sum::<usize>());
|
||||
match self.0.checked_sub(n) {
|
||||
Some(rest) => {
|
||||
self.0 = rest;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.0 = 0;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline character format.
|
||||
@@ -259,7 +293,7 @@ pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
continue;
|
||||
}
|
||||
} else if n == Some('M') && plus == Some('+') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && hex4(&chars, i + 4).is_some() {
|
||||
push_literal(&mut out, cadcraft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
push_literal(&mut out, craft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
@@ -850,6 +884,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
let insert = p.insert;
|
||||
let xf = |q: Vec2| insert + q.rotate(rotation);
|
||||
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
|
||||
let mut allowance = Allowance::new();
|
||||
for (l, base_y) in lines.iter().zip(baselines) {
|
||||
let x0 = match col {
|
||||
0 => 0.0,
|
||||
@@ -859,7 +894,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
for it in &l.items {
|
||||
let Some(sg) = &it.seg else { continue };
|
||||
let fm = fmt(it.fmt);
|
||||
if !allowance.left() {
|
||||
out.truncated = true;
|
||||
continue;
|
||||
}
|
||||
let mut sh = seg_shape(sg, fm, it.x);
|
||||
if !allowance.charge(&sh) {
|
||||
out.truncated = true;
|
||||
continue;
|
||||
}
|
||||
let off = Vec2::new(x0, top + base_y);
|
||||
sh.map(|q| xf(q + off));
|
||||
match out.pieces.last_mut() {
|
||||
@@ -896,6 +939,17 @@ mod tests {
|
||||
assert_eq!(l.lines[0], "alpha beta gamma");
|
||||
}
|
||||
|
||||
/// The points of all the segments of one layout are charged against one allowance.
|
||||
#[test]
|
||||
fn a_layout_shares_one_point_allowance() {
|
||||
let big = Shaped { strokes: vec![vec![Vec2::ZERO; crate::ttf::MAX_SHAPED_POINTS / 4 + 1]], ..Default::default() };
|
||||
let mut a = Allowance::new();
|
||||
assert!(a.left() && a.charge(&big), "the first segment fits");
|
||||
assert!(!a.charge(&big), "the second does not, though each alone would");
|
||||
assert!(!a.left(), "and nothing is left to shape another");
|
||||
assert!(!layout_mtext("plain text", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0).truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_formatting() {
|
||||
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
|
||||
|
||||
@@ -677,6 +677,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
|
||||
let insert = p.insert;
|
||||
let xf = |q: Vec2| insert + q.rotate(rotation);
|
||||
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
|
||||
let mut allowance = super::Allowance::new();
|
||||
for (l, (c, base_y)) in lines.iter().zip(placed) {
|
||||
let pp = paras.props(l.para);
|
||||
let slot = if reversed { n_cols.saturating_sub(1).saturating_sub(c) } else { c };
|
||||
@@ -696,7 +697,15 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
|
||||
};
|
||||
for it in &l.items {
|
||||
let fm = fmt(it.fmt);
|
||||
if !allowance.left() {
|
||||
out.truncated = true;
|
||||
continue;
|
||||
}
|
||||
let mut sh = seg_shape(&it.seg, fm, it.x);
|
||||
if !allowance.charge(&sh) {
|
||||
out.truncated = true;
|
||||
continue;
|
||||
}
|
||||
let off = Vec2::new(col_x + shift + per_gap * it.gap as f64, top + base_y);
|
||||
sh.map(|q| xf(q + off));
|
||||
match out.pieces.last_mut() {
|
||||
|
||||
+10
-2
@@ -103,7 +103,11 @@ impl Face {
|
||||
let Some(f) = self.font() else { return Vec::new() };
|
||||
[skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME]
|
||||
.into_iter()
|
||||
.flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::<Vec<_>>())
|
||||
// A name table can hold many records that point at one big string: at most
|
||||
// MAX_NAME_RECORDS per name id, each decoded only up to MAX_NAME_CHARS characters.
|
||||
.flat_map(|id| {
|
||||
f.localized_strings(id).take(MAX_NAME_RECORDS).map(|s| norm(&s.chars().take(MAX_NAME_CHARS).collect::<String>())).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -118,6 +122,10 @@ pub fn faces(bytes: &Arc<Vec<u8>>) -> Vec<Face> {
|
||||
}
|
||||
|
||||
const MAX_FACES_PER_FILE: u32 = 32;
|
||||
/// Name records read per name id of a face, and characters read of each: real fonts have a few
|
||||
/// dozen short ones.
|
||||
const MAX_NAME_RECORDS: usize = 64;
|
||||
const MAX_NAME_CHARS: usize = 256;
|
||||
/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character.
|
||||
const MAX_FALLBACK_FILES: usize = 24;
|
||||
const MAX_FALLBACK_BYTES: usize = 160 << 20;
|
||||
@@ -924,7 +932,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GlyphOutline {
|
||||
pub(crate) contours: Arc<[Vec<Vec2>]>,
|
||||
points: usize,
|
||||
pub(crate) points: usize,
|
||||
pub(crate) advance: f64,
|
||||
pub(crate) cap: f64,
|
||||
/// The font maps the character (false: the missing-glyph shape).
|
||||
|
||||
@@ -431,10 +431,10 @@ fn outside(core: &Graph, u: usize, steps: usize) -> Option<Vec<Vec2>> {
|
||||
}
|
||||
|
||||
/// Outlines of the groups of curves inside the face `outer` (vertex indices into `g`).
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
|
||||
let comp = components(g);
|
||||
let outer_comp: std::collections::HashSet<usize> = outer.iter().filter_map(|&i| comp.get(i).copied()).collect();
|
||||
let Some(outer_pts) = points(g, outer) else { return Vec::new() };
|
||||
let outer_pts = points(g, outer)?;
|
||||
let ob = Bounds2::from_points(outer_pts.iter().copied());
|
||||
let core = prune(g);
|
||||
// Leftmost (then lowest) vertex still on a cycle, per component.
|
||||
@@ -464,19 +464,32 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
let mut starts: Vec<(usize, usize)> = start.into_iter().collect();
|
||||
starts.sort_unstable();
|
||||
let mut out = Vec::new();
|
||||
// Each containment test scans a whole polygon, accepted or not: they share one allowance of
|
||||
// polygon edges, and the search ends when it is spent.
|
||||
let mut edges_left: usize = 100_000_000;
|
||||
for (c, u) in starts {
|
||||
if out.len() >= MAX_ISLANDS {
|
||||
break;
|
||||
}
|
||||
let Some(&pu) = core.pts.get(u) else { continue };
|
||||
if !ob.contains(pu) || !point_in_polygon(&outer_pts, pu) {
|
||||
if !ob.contains(pu) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(outer_pts.len())?;
|
||||
edges_left = left;
|
||||
if !point_in_polygon(&outer_pts, pu) {
|
||||
continue;
|
||||
}
|
||||
let steps = edges.get(&c).copied().unwrap_or(0).saturating_add(4);
|
||||
let Some(lp) = outside(&core, u, steps) else { continue };
|
||||
let area = shoelace(&lp);
|
||||
let ib = Bounds2::from_points(lp.iter().copied());
|
||||
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) || point_in_polygon(&lp, p) {
|
||||
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(lp.len())?;
|
||||
edges_left = left;
|
||||
if point_in_polygon(&lp, p) {
|
||||
continue;
|
||||
}
|
||||
let mut src = sources.remove(&c).unwrap_or_default();
|
||||
@@ -484,7 +497,7 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
src.dedup();
|
||||
out.push(Island { outline: lp, sources: src });
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
|
||||
@@ -497,6 +510,10 @@ fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, bu
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
// A curve with no segment costs a visit too, so many of them can't multiply the searches.
|
||||
if other.len() < 2 {
|
||||
*budget = budget.checked_sub(1).ok_or(())?;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if *budget == 0 {
|
||||
return Err(());
|
||||
@@ -598,7 +615,7 @@ pub fn boundary_at_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Opti
|
||||
let g = build_gap(polys, tol, gap)?;
|
||||
let ids = outer_face(&g, p)?;
|
||||
let outer = points(&g, &ids)?;
|
||||
let islands = islands(&g, &ids, p);
|
||||
let islands = islands(&g, &ids, p)?;
|
||||
let mut outer_sources: Vec<usize> =
|
||||
ids.iter().zip(ids.iter().cycle().skip(1)).filter_map(|(&u, &v)| g.edge_src.get(&(u.min(v), u.max(v))).copied()).collect();
|
||||
outer_sources.sort_unstable();
|
||||
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-dwg = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -844,15 +844,33 @@ pub(crate) fn apply_anno(d: &mut cadcraft_doc::Drawing, p: AnnoPayload) {
|
||||
std::sync::Arc::make_mut(b).annotative = true;
|
||||
}
|
||||
}
|
||||
// Which layout holds each paper space object, found once: `modify_entity` searches every
|
||||
// layout for a handle model space lacks, which a long list of missing handles made quadratic.
|
||||
let mut in_layout: std::collections::HashMap<Handle, usize> = Default::default();
|
||||
if !p.objects.is_empty() {
|
||||
// First layout wins, as `modify_entity` did.
|
||||
for (i, l) in d.layouts.iter().enumerate() {
|
||||
for h in l.entities.handles() {
|
||||
in_layout.entry(h).or_insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for o in p.objects {
|
||||
let Some(h) = Handle::parse_hex(&o.handle) else { continue };
|
||||
let store = if d.model.contains(h) {
|
||||
&mut d.model
|
||||
} else if let Some(l) = in_layout.get(&h).and_then(|&i| d.layouts.get_mut(i)) {
|
||||
&mut l.entities
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut scales: Vec<cadcraft_doc::AnnoScale> = Vec::new();
|
||||
for s in o.scales.into_iter().filter(|s| s.is_valid() && !s.name.trim().is_empty()) {
|
||||
if scales.len() < cadcraft_doc::MAX_SCALES && !scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) {
|
||||
scales.push(s);
|
||||
}
|
||||
}
|
||||
let _ = d.modify_entity(h, |e| {
|
||||
store.modify(h, |e| {
|
||||
if cadcraft_doc::can_be_annotative(&e.kind) {
|
||||
e.common.anno_scales = scales;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::foreign::{Foreign, ForeignExtra, ForeignKey, ForeignObject, class_group, class_index, class_number};
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, RawTag, Unknown};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use craft_dxf::{Section, Tag};
|
||||
|
||||
/// Named object dictionary entries CADCraft writes itself from its model (plus every key that
|
||||
/// starts with `CADCRAFT`): they are read into the model, not kept raw. A change that starts
|
||||
@@ -71,7 +71,7 @@ fn raw(t: &Tag) -> RawTag {
|
||||
/// A kept group with the value type its code has (so binary output encodes it right); text that
|
||||
/// doesn't parse as that type stays text.
|
||||
fn tag(r: &RawTag) -> Tag {
|
||||
use cadcraft_dxf::{Kind, Value, kind_of};
|
||||
use craft_dxf::{Kind, Value, kind_of};
|
||||
let v = r.value.trim();
|
||||
let value = match kind_of(r.code) {
|
||||
Kind::Real => v.parse::<f64>().ok().filter(|x| x.is_finite()).map(Value::Real),
|
||||
@@ -339,7 +339,8 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
}
|
||||
let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).and_then(|(_, _, t)| head(t).handle);
|
||||
let root_entries = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, _, t)| entries(t)).unwrap_or_default();
|
||||
let root_key = |h: &str| root_entries.iter().find(|(_, eh)| eh == h).map(|(k, _)| k.clone());
|
||||
let root_keys = root_key_index(&root_entries);
|
||||
let root_key = |h: &str| root_keys.get(h).map(|k| (*k).to_string());
|
||||
// An extension dictionary of an entity or record (not of another object).
|
||||
let is_xdict = |k: usize| {
|
||||
let Some((_, _, t)) = objects.get(k) else { return false };
|
||||
@@ -494,6 +495,16 @@ fn extension_dictionaries(
|
||||
dictionaries
|
||||
}
|
||||
|
||||
/// The root dictionary's key for each entry handle (the first key when several name one handle),
|
||||
/// built once: a lookup per object scanning the entries made a large dictionary quadratic.
|
||||
fn root_key_index(root_entries: &[(String, String)]) -> HashMap<&str, &str> {
|
||||
let mut index = HashMap::with_capacity(root_entries.len());
|
||||
for (key, handle) in root_entries {
|
||||
index.entry(handle.as_str()).or_insert(key.as_str());
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Determine which objects belong to unmodelled dictionary entries or owners.
|
||||
fn retained_objects(
|
||||
objects: &[(String, usize, &[Tag])],
|
||||
@@ -504,7 +515,8 @@ fn retained_objects(
|
||||
root_entries: &[(String, String)],
|
||||
xdict_of: &HashMap<String, String>,
|
||||
) -> HashMap<usize, bool> {
|
||||
let root_key = |handle: &str| root_entries.iter().find(|(_, entry_handle)| entry_handle == handle).map(|(key, _)| key);
|
||||
let root_keys = root_key_index(root_entries);
|
||||
let root_key = |handle: &str| root_keys.get(handle).copied();
|
||||
let is_xdict = |index: usize| {
|
||||
let Some((_, _, tags)) = objects.get(index) else { return false };
|
||||
let header = head(tags);
|
||||
|
||||
@@ -9,8 +9,8 @@ use cadcraft_doc::{
|
||||
Linetype, Lineweight, LwPolyline, MAX_FRAME_CHARS, MLeaderStyle, MText, MultilineAttrib, NamedView, PageSetup, Point, Polyline3d, RawTag,
|
||||
RayLine, Solid, Table, TableCell, Text, TextStyle, Tolerance, Transparency, Ucs, Unknown, VAlign, Viewport, VportConfig, VportTile, Wipeout,
|
||||
};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
@@ -84,7 +84,7 @@ type LayoutDef = (String, u32, String, PageSetup, Option<(Vec2, Vec2)>);
|
||||
|
||||
/// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections.
|
||||
fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
|
||||
let tags = craft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
|
||||
let secs = sections(&tags);
|
||||
if secs.is_empty() {
|
||||
return Err(IoError::Format("no sections found".into()));
|
||||
|
||||
@@ -420,7 +420,7 @@ pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
|
||||
|
||||
/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the
|
||||
/// named object dictionary (the first object), by placeholder handle.
|
||||
pub(super) fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap<String, String> {
|
||||
pub(super) fn plot_style_names(secs: &[craft_dxf::Section]) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out };
|
||||
let recs = records(&objects.tags);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! `^2` in `10^2` as a control character.
|
||||
|
||||
use cadcraft_doc::{MText, MTextColumnType, MTextColumns};
|
||||
use cadcraft_dxf::Tag;
|
||||
use craft_dxf::Tag;
|
||||
|
||||
/// Upper bound on column heights read from a file.
|
||||
const MAX_COLUMNS: usize = 1_000;
|
||||
|
||||
@@ -33,9 +33,9 @@ pub(super) fn header(tags: &[Tag], d: &mut Drawing) {
|
||||
[x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())),
|
||||
[x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)),
|
||||
[v] => match &v.value {
|
||||
cadcraft_dxf::Value::Real(r) => HVal::Real(*r),
|
||||
cadcraft_dxf::Value::Int(n) => HVal::Int(*n),
|
||||
cadcraft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)),
|
||||
craft_dxf::Value::Real(r) => HVal::Real(*r),
|
||||
craft_dxf::Value::Int(n) => HVal::Int(*n),
|
||||
craft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)),
|
||||
other => HVal::Str(other.as_str()),
|
||||
},
|
||||
_ => continue,
|
||||
|
||||
@@ -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 cadcraft_dxf::Tag;
|
||||
use craft_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 {
|
||||
cadcraft_dxf::write_ascii(&tags(d, Version::R2000))
|
||||
craft_dxf::write_ascii(&tags(d, Version::R2000))
|
||||
}
|
||||
|
||||
/// The drawing's DXF tags, declaring `version` in `$ACADVER`.
|
||||
@@ -124,7 +124,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {
|
||||
let mut tags = tags_for(d, Version::R2000, dwg, false);
|
||||
bridge_markers(&mut tags);
|
||||
cadcraft_dxf::write_ascii(&tags)
|
||||
craft_dxf::write_ascii(&tags)
|
||||
}
|
||||
|
||||
/// Proxy type names and lookup-parameter subclass names must match the bridge reader's spelling.
|
||||
|
||||
@@ -144,6 +144,6 @@ pub(super) fn header_vars(w: &mut W, d: &Drawing) {
|
||||
/// from 2007 (UTF-8 text), `ANSI_1252`.
|
||||
pub(crate) fn code_page(d: &Drawing, version: Version) -> String {
|
||||
let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default();
|
||||
let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some();
|
||||
let known = read.is_ascii() && read.len() <= 32 && craft_dxf::decode_code_page(b"", read).is_some();
|
||||
if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() }
|
||||
}
|
||||
|
||||
@@ -1204,33 +1204,56 @@ fn add_class_instance_counts(tags: &mut Vec<Tag>, target: Version) {
|
||||
return;
|
||||
}
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
let mut classes = 0;
|
||||
for t in tags.iter().filter(|t| t.code == 0) {
|
||||
let kind = t.str();
|
||||
if kind != "CLASS" {
|
||||
*counts.entry(kind.to_ascii_uppercase()).or_default() += 1;
|
||||
} else {
|
||||
classes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
while start < tags.len() {
|
||||
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
|
||||
start += 1;
|
||||
// One pass into a new list: inserting each missing count in place shifted every later tag,
|
||||
// quadratic in the number of CLASS records.
|
||||
// Tags move by value (no clone); the old and new buffers coexist while it runs. At most one
|
||||
// group 91 is added per CLASS record, so that is all the room the new list needs beyond the old one.
|
||||
let old = std::mem::take(tags);
|
||||
tags.reserve(old.len() + classes);
|
||||
let mut it = old.into_iter();
|
||||
let mut pending = it.next();
|
||||
while let Some(t) = pending.take() {
|
||||
if t.code != 0 || t.str() != "CLASS" {
|
||||
tags.push(t);
|
||||
pending = it.next();
|
||||
continue;
|
||||
}
|
||||
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
|
||||
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let mut record = vec![t];
|
||||
for n in it.by_ref() {
|
||||
if n.code == 0 {
|
||||
pending = Some(n);
|
||||
break;
|
||||
}
|
||||
record.push(n);
|
||||
}
|
||||
let name = record.iter().skip(1).find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let count = counts.get(&name).copied().unwrap_or(0);
|
||||
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
|
||||
tags[start + 1 + i] = Tag::i(91, count);
|
||||
false
|
||||
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
|
||||
tags.insert(start + 2 + i, Tag::i(91, count));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Inserting the group shifts the next record's start by one.
|
||||
start = end + usize::from(inserted);
|
||||
// The first 91 gets the count; without one, a 91 goes in after the first 90.
|
||||
let mut added = record.iter().skip(1).any(|t| t.code == 91);
|
||||
let mut replaced = false;
|
||||
for (i, t) in record.into_iter().enumerate() {
|
||||
let code = t.code;
|
||||
if i > 0 && code == 91 && !replaced {
|
||||
tags.push(Tag::i(91, count));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
tags.push(t);
|
||||
if i > 0 && code == 90 && !added {
|
||||
tags.push(Tag::i(91, count));
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,3 +1281,31 @@ fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod class_count_tests {
|
||||
use super::*;
|
||||
|
||||
fn record(kind: &str, name: &str, extra: &[Tag]) -> Vec<Tag> {
|
||||
let mut r = vec![Tag::s(0, kind), Tag::s(1, name), Tag::i(90, 0)];
|
||||
r.extend_from_slice(extra);
|
||||
r
|
||||
}
|
||||
|
||||
/// The first 91 gets the count, a CLASS without one gets it after its 90, in one pass over many records.
|
||||
#[test]
|
||||
fn counts_are_set_and_added_in_one_pass() {
|
||||
let mut tags = record("CLASS", "FOO", &[]);
|
||||
tags.extend(record("CLASS", "BAR", &[Tag::i(91, 7), Tag::i(91, 8)]));
|
||||
for i in 0..50_000 {
|
||||
tags.extend(record("CLASS", &format!("X{i}"), &[]));
|
||||
}
|
||||
tags.push(Tag::s(0, "FOO"));
|
||||
let started = std::time::Instant::now();
|
||||
add_class_instance_counts(&mut tags, Version::R2018);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
assert_eq!(&tags[..4], &[Tag::s(0, "CLASS"), Tag::s(1, "FOO"), Tag::i(90, 0), Tag::i(91, 1)]);
|
||||
assert_eq!(&tags[4..9], &[Tag::s(0, "CLASS"), Tag::s(1, "BAR"), Tag::i(90, 0), Tag::i(91, 0), Tag::i(91, 8)]);
|
||||
assert_eq!(tags.iter().filter(|t| t.code == 91).count(), 50_003);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use cadcraft_geom::Bounds2;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use cadcraft_doc::Format;
|
||||
pub use cadcraft_dxf::Version;
|
||||
pub use craft_dxf::Version;
|
||||
pub use dxf_read::entity_from_groups;
|
||||
pub use dxf_write::entity_groups;
|
||||
pub use pdf::{PdfOptions, pdf, plot};
|
||||
@@ -114,10 +114,10 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
|
||||
// Before 2007 the text is in the code page the header declares.
|
||||
let code_page = dxf_write::code_page(d, version);
|
||||
Ok(match (opts.binary, version.utf8()) {
|
||||
(true, true) => cadcraft_dxf::write_binary(&tags, true),
|
||||
(true, false) => cadcraft_dxf::write_binary_in(&tags, &code_page),
|
||||
(false, true) => cadcraft_dxf::write_ascii_as(&tags, true).into_bytes(),
|
||||
(false, false) => cadcraft_dxf::write_ascii_in(&tags, &code_page),
|
||||
(true, true) => craft_dxf::write_binary(&tags, true),
|
||||
(true, false) => craft_dxf::write_binary_in(&tags, &code_page),
|
||||
(false, true) => craft_dxf::write_ascii_as(&tags, true).into_bytes(),
|
||||
(false, false) => craft_dxf::write_ascii_in(&tags, &code_page),
|
||||
})
|
||||
}
|
||||
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
|
||||
|
||||
+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: &[cadcraft_dxf::Tag]) -> bool {
|
||||
fn is_overall_viewport(t: &str, g: &[craft_dxf::Tag]) -> bool {
|
||||
t == "VIEWPORT" && g.iter().any(|x| x.code == 69 && x.i64() == 1)
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ fn is_space_block(name: &str) -> bool {
|
||||
|
||||
/// Count what a DXF file (ASCII or binary) holds; `None` when it can't be parsed.
|
||||
pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
let tags = cadcraft_dxf::parse(dxf).ok()?;
|
||||
let tags = craft_dxf::parse(dxf).ok()?;
|
||||
let mut c = Census::default();
|
||||
// Blocks CADCraft generates (tables' blocks, standard arrowheads nothing inserts) are left out
|
||||
// on read and rebuilt on save: counted apart.
|
||||
let mut table_blocks: HashSet<String> = HashSet::new();
|
||||
let mut inserted: HashSet<String> = HashSet::new();
|
||||
let mut blocks: Vec<(String, BTreeMap<String, usize>)> = Vec::new();
|
||||
let mut note_refs = |t: &str, g: &[cadcraft_dxf::Tag]| {
|
||||
let mut note_refs = |t: &str, g: &[craft_dxf::Tag]| {
|
||||
let name = || g.iter().find(|x| x.code == 2).map(|x| x.str().trim().to_ascii_uppercase());
|
||||
match t {
|
||||
"ACAD_TABLE" => table_blocks.extend(name()),
|
||||
@@ -65,8 +65,8 @@ pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
for s in cadcraft_dxf::sections(&tags) {
|
||||
let records = cadcraft_dxf::records(&s.tags);
|
||||
for s in craft_dxf::sections(&tags) {
|
||||
let records = craft_dxf::records(&s.tags);
|
||||
match s.name.trim().to_ascii_uppercase().as_str() {
|
||||
"ENTITIES" => {
|
||||
for (t, g) in &records {
|
||||
@@ -82,7 +82,7 @@ pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
note_refs(t, g);
|
||||
match t.as_str() {
|
||||
"BLOCK" => {
|
||||
let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default();
|
||||
let name = g.iter().find(|x| x.code == 2).map(craft_dxf::Tag::str).unwrap_or_default();
|
||||
space = is_space_block(&name);
|
||||
if !space {
|
||||
blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new()));
|
||||
|
||||
@@ -590,7 +590,10 @@ fn hidden(e: &BytesStart) -> bool {
|
||||
}
|
||||
|
||||
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
|
||||
for a in e.attributes().flatten() {
|
||||
// Stop at the first malformed attribute: skipping errors resumed the parser's duplicate check,
|
||||
// which rescans the attributes before it each time, so many duplicates were quadratic.
|
||||
for a in e.attributes() {
|
||||
let Ok(a) = a else { return None };
|
||||
if a.key.into_inner().as_bytes() == name {
|
||||
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
|
||||
}
|
||||
|
||||
+18
-8
@@ -396,6 +396,16 @@ fn many_distinct_symbol_names_read_quickly() {
|
||||
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
|
||||
}
|
||||
|
||||
/// Elements with many duplicate attributes are read in bounded time.
|
||||
#[test]
|
||||
fn svg_duplicate_attributes_are_not_rescanned() {
|
||||
let dup = " x=\"1\"".repeat(20_000);
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>", format!("<rect{dup} width=\"1\" height=\"1\"/>").repeat(20));
|
||||
let started = std::time::Instant::now();
|
||||
let _ = read(svg.as_bytes(), "dup.svg");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dxf_does_not_panic() {
|
||||
for t in [
|
||||
@@ -1483,7 +1493,7 @@ fn ac1024_dwg_with_many_entities_opens() {
|
||||
let n = 30_000;
|
||||
let dwg = ac1024_dwg(n);
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap().len();
|
||||
let tags = craft_dxf::parse(&dxf).unwrap().len();
|
||||
assert!(dxf.len() < dwg.len() * 20, "DXF {} bytes from {} DWG bytes", dxf.len(), dwg.len());
|
||||
assert!(tags < dwg.len(), "{tags} group codes from {} DWG bytes", dwg.len());
|
||||
let d = read(&dwg, "big.dwg").unwrap();
|
||||
@@ -1512,7 +1522,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
|
||||
|
||||
// Lines that aren't even group codes: the size is reported before anything is parsed.
|
||||
let text = "not a group code\nx\n".repeat(60);
|
||||
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
|
||||
let e = craft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
|
||||
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
|
||||
}
|
||||
|
||||
@@ -1608,7 +1618,7 @@ fn dimension_associativity_roundtrips() {
|
||||
|
||||
// Without CADCraft's xdata (a file from another writer), the standard DIMASSOC objects
|
||||
// still give the extension-line links.
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let mut stripped = Vec::new();
|
||||
let mut skipping = false;
|
||||
for t in tags {
|
||||
@@ -1621,7 +1631,7 @@ fn dimension_associativity_roundtrips() {
|
||||
stripped.push(t);
|
||||
}
|
||||
}
|
||||
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
let foreign = read_dxf(craft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
assert_eq!(assoc_of(&foreign, hl), lin.assoc);
|
||||
let fa = assoc_of(&foreign, ha);
|
||||
assert_eq!(fa.len(), 2);
|
||||
@@ -1697,7 +1707,7 @@ fn polyline_vertex_dimensions_round_trip_by_segment() {
|
||||
];
|
||||
let hp = d.add(&Space::Model, c(), EntityKind::Dimension(on_pl.clone())).unwrap();
|
||||
let text = write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
// Each reference to the polyline: (72, 73, 91).
|
||||
let refs: Vec<(i64, i64, i64)> = tags
|
||||
.iter()
|
||||
@@ -1726,13 +1736,13 @@ fn polyline_vertex_dimensions_round_trip_by_segment() {
|
||||
in_ref = t.str() == "AcDbOsnapPointRef";
|
||||
}
|
||||
if in_ref && matches!(t.code, 10 | 20) {
|
||||
t = cadcraft_dxf::Tag::new(t.code, cadcraft_dxf::Value::Real(0.0));
|
||||
t = craft_dxf::Tag::new(t.code, craft_dxf::Value::Real(0.0));
|
||||
}
|
||||
if !skipping {
|
||||
stripped.push(t);
|
||||
}
|
||||
}
|
||||
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
let foreign = read_dxf(craft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
match &foreign.entity(hp).unwrap().kind {
|
||||
EntityKind::Dimension(x) => assert_eq!(x.assoc, on_pl.assoc),
|
||||
_ => panic!(),
|
||||
@@ -2041,7 +2051,7 @@ fn non_ascii_text_is_escaped_for_r2000_and_roundtrips() {
|
||||
let d = intl_sample();
|
||||
let text = write_dxf(&d);
|
||||
assert!(text.is_ascii(), "an AC1015 DXF must not contain UTF-8 bytes");
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let header = |name: &str| tags.windows(2).find(|w| w[0].code == 9 && w[0].str() == name).map(|w| w[1].str());
|
||||
assert_eq!(header("$ACADVER").as_deref(), Some("AC1015"));
|
||||
assert_eq!(header("$DWGCODEPAGE").as_deref(), Some("ANSI_1252"));
|
||||
|
||||
@@ -173,16 +173,16 @@ fn cyclic_hard_owners_with_a_live_holder_stay_kept() {
|
||||
let bytes = write(&drawing, name).unwrap();
|
||||
drawing = read(&bytes, name).unwrap();
|
||||
let text = write_dxf(&drawing);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let objects = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "OBJECTS").unwrap();
|
||||
let records = cadcraft_dxf::records(&objects.tags);
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let objects = craft_dxf::sections(&tags).into_iter().find(|s| s.name == "OBJECTS").unwrap();
|
||||
let records = craft_dxf::records(&objects.tags);
|
||||
let record = |label: &str| records.iter().find(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == label)).unwrap().1.clone();
|
||||
let first = record("first");
|
||||
let second = record("second");
|
||||
let holder = record("holder");
|
||||
let child = record("child");
|
||||
assert!(!records.iter().any(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == "stray")), "{name}");
|
||||
let handle = |tags: &[cadcraft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
|
||||
let handle = |tags: &[craft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&second)), "{name}");
|
||||
assert!(second.iter().any(|t| t.code == 360 && t.str() == handle(&first)), "{name}");
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&child)), "{name}");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Snap/grid spacing uses legacy HEADER variables and the current *ACTIVE VPORT (#607).
|
||||
|
||||
use cadcraft_doc::{Drawing, HVal};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn header(snap: Vec2, grid: Vec2) -> Vec<Tag> {
|
||||
let mut tags = Vec::new();
|
||||
@@ -24,7 +24,7 @@ fn read(header: &[Tag], vports: &[Vec<Tag>]) -> Drawing {
|
||||
tags.extend_from_slice(vp);
|
||||
}
|
||||
tags.extend([Tag::s(0, "ENDTAB"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
cadcraft_io::read_dxf(cadcraft_dxf::write_ascii(&tags).as_bytes()).unwrap()
|
||||
cadcraft_io::read_dxf(craft_dxf::write_ascii(&tags).as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_units(d: &Drawing, snap: Vec2, grid: Vec2) {
|
||||
@@ -43,7 +43,7 @@ fn writes_spacing_to_header_and_active_vport_and_roundtrips() {
|
||||
d.header.set("SNAPUNIT", HVal::Point(snap.to3(0.0)));
|
||||
d.header.set("GRIDUNIT", HVal::Point(grid.to3(0.0)));
|
||||
let text = cadcraft_io::write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse_ascii(&text).unwrap();
|
||||
let tags = craft_dxf::parse_ascii(&text).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let header = §ions.iter().find(|s| s.name == "HEADER").unwrap().tags;
|
||||
for (name, unit) in [("$SNAPUNIT", snap), ("$GRIDUNIT", grid)] {
|
||||
|
||||
@@ -62,7 +62,7 @@ fn opts(version: Version, binary: bool) -> SaveOptions {
|
||||
}
|
||||
|
||||
fn acadver(bytes: &[u8]) -> String {
|
||||
let tags = cadcraft_dxf::parse(bytes).unwrap();
|
||||
let tags = craft_dxf::parse(bytes).unwrap();
|
||||
tags.windows(2).find(|w| w[0].code == 9 && w[0].str() == "$ACADVER").map(|w| w[1].str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ fn dxf_at_every_version_reads_back_the_same() {
|
||||
let bytes = write_with(&d, "x.dxf", &opts(v, binary)).unwrap();
|
||||
let what = format!("{v} binary={binary}");
|
||||
assert_eq!(acadver(&bytes), v.code(), "{what}");
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{what}");
|
||||
let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes());
|
||||
assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, the code page before");
|
||||
if !v.utf8() {
|
||||
@@ -109,11 +109,11 @@ fn class_records_follow_the_version() {
|
||||
let d = sample();
|
||||
for v in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let tags = cadcraft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
|
||||
let classes: Vec<Vec<i32>> = cadcraft_dxf::sections(&tags)
|
||||
let tags = craft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
|
||||
let classes: Vec<Vec<i32>> = craft_dxf::sections(&tags)
|
||||
.into_iter()
|
||||
.filter(|s| s.name == "CLASSES")
|
||||
.flat_map(|s| cadcraft_dxf::records(&s.tags))
|
||||
.flat_map(|s| craft_dxf::records(&s.tags))
|
||||
.filter(|(kind, _)| kind == "CLASS")
|
||||
.map(|(_, t)| t.iter().map(|x| x.code).collect())
|
||||
.collect();
|
||||
@@ -134,7 +134,7 @@ fn r2000_ascii_is_unchanged() {
|
||||
let saved = write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap();
|
||||
let escaped = write_dxf(&d);
|
||||
assert_ne!(saved, escaped.as_bytes(), "Café is written in ANSI_1252");
|
||||
assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(escaped.as_bytes()).unwrap());
|
||||
assert_eq!(craft_dxf::parse(&saved).unwrap(), craft_dxf::parse(escaped.as_bytes()).unwrap());
|
||||
}
|
||||
|
||||
/// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! don't panic.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Helix, HelixConstrain, Space};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn helices(d: &Drawing) -> Vec<Helix> {
|
||||
d.model
|
||||
@@ -65,7 +65,7 @@ fn helix_survives_save_and_reopen() {
|
||||
assert_eq!(back.model.iter().next().unwrap().common.layer, "Springs");
|
||||
|
||||
// The file: a spline whose last control point is the helix's end, the helix data, a CLASS.
|
||||
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let secs = sections(&tags);
|
||||
let recs = |name: &str| records(&secs.iter().find(|s| s.name == name).unwrap().tags);
|
||||
let ents = recs("ENTITIES");
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, DimStyle, Drawing, EntityKind, Handle, Leader, Lineweight, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::Tag;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
@@ -93,7 +93,7 @@ fn missing_z_defaults_only_the_corresponding_vertex() -> TestResult {
|
||||
expected.push(Vec3::new(p.x, p.y, if has_z { p.z } else { 0.0 }));
|
||||
}
|
||||
tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
let dxf = cadcraft_dxf::write_ascii(&tags);
|
||||
let dxf = craft_dxf::write_ascii(&tags);
|
||||
let d = read(dxf.as_bytes(), "missing-z.dxf")?;
|
||||
let e = d.entity(Handle(0x10)).ok_or("LEADER missing")?;
|
||||
let EntityKind::Leader(l) = &e.kind else { return Err("expected LEADER".into()) };
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, MLine, MLineCaps, MLineCut, MLineElement, MLineJust, MLineStyle, Space};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec2;
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn mlines(d: &Drawing) -> Vec<MLine> {
|
||||
d.model
|
||||
@@ -84,7 +84,7 @@ fn mlines_and_styles_survive_save_and_reopen() {
|
||||
assert_eq!(back.header.f64("CMLSCALE", 0.0), 2.5);
|
||||
|
||||
// The file links up as the DXF Reference describes.
|
||||
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let secs = sections(&tags);
|
||||
let recs = |name: &str| records(&secs.iter().find(|s| s.name == name).unwrap().tags);
|
||||
let val = |t: &[Tag], c: i32| t.iter().find(|g| g.code == c).map(Tag::str).unwrap_or_default();
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! 75/76/78/79/48/49/50, tabs as `^I`, carets as `^ `.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, entity_from_groups, read_dxf, write_dxf, write_with};
|
||||
use craft_dxf::Tag;
|
||||
|
||||
fn mtext(contents: &str, rotation: f64, columns: Option<MTextColumns>) -> MText {
|
||||
MText {
|
||||
@@ -246,8 +246,8 @@ fn markers_inside_an_application_group_do_not_end_the_entity_groups() {
|
||||
|
||||
// ---- #2248: R2018 embedded column object ----
|
||||
|
||||
fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<cadcraft_dxf::Tag> {
|
||||
use cadcraft_dxf::Tag;
|
||||
fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<craft_dxf::Tag> {
|
||||
use craft_dxf::Tag;
|
||||
let mut v = vec![
|
||||
Tag::s(8, "0"),
|
||||
Tag::f(10, 10.0),
|
||||
@@ -281,7 +281,7 @@ fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<ca
|
||||
v
|
||||
}
|
||||
|
||||
fn mtext_from_groups(tags: &[cadcraft_dxf::Tag]) -> (Common, MText) {
|
||||
fn mtext_from_groups(tags: &[craft_dxf::Tag]) -> (Common, MText) {
|
||||
let Some((common, EntityKind::MText(t))) = entity_from_groups("MTEXT", tags) else { panic!("MTEXT was not read") };
|
||||
(common, t)
|
||||
}
|
||||
@@ -322,7 +322,7 @@ fn embedded_column_height_is_the_entitys_defined_height() {
|
||||
// As ezdxf and AutoCAD write static columns: the height in the entity's 46, none in the object.
|
||||
let mut tags = embedded_tags(3, 12.5, 2.5, &[]);
|
||||
let at = tags.iter().position(|t| t.code == 101).unwrap();
|
||||
tags.insert(at, cadcraft_dxf::Tag::f(46, 20.0));
|
||||
tags.insert(at, craft_dxf::Tag::f(46, 20.0));
|
||||
let (_, t) = mtext_from_groups(&tags);
|
||||
assert_eq!(t.columns.map(|c| c.height), Some(20.0));
|
||||
}
|
||||
@@ -355,10 +355,10 @@ fn dynamic_auto_height_embedded_count_comes_from_the_total_width() {
|
||||
let at = tags.iter().position(|t| t.code == 101).unwrap();
|
||||
for t in &mut tags[at..] {
|
||||
*t = match t.code {
|
||||
42 => cadcraft_dxf::Tag::f(42, 175.0),
|
||||
71 => cadcraft_dxf::Tag::i(71, 2),
|
||||
72 => cadcraft_dxf::Tag::i(72, 0),
|
||||
73 => cadcraft_dxf::Tag::i(73, 1),
|
||||
42 => craft_dxf::Tag::f(42, 175.0),
|
||||
71 => craft_dxf::Tag::i(71, 2),
|
||||
72 => craft_dxf::Tag::i(72, 0),
|
||||
73 => craft_dxf::Tag::i(73, 1),
|
||||
_ => t.clone(),
|
||||
};
|
||||
}
|
||||
@@ -373,13 +373,13 @@ fn dynamic_auto_height_count_comes_from_the_widths_when_72_is_zero() {
|
||||
// A dynamic auto-height object stores 0 in 72; the count is implied by the total width
|
||||
// (42 = n * 44 + (n - 1) * 45): (175 + 12.5) / (50 + 12.5) = 3.
|
||||
let mut tags = embedded_tags(0, 50.0, 12.5, &[]);
|
||||
let mut set = |code: i32, v: cadcraft_dxf::Tag| {
|
||||
let mut set = |code: i32, v: craft_dxf::Tag| {
|
||||
let at = tags.iter().rposition(|t| t.code == code).unwrap();
|
||||
tags[at] = v;
|
||||
};
|
||||
set(71, cadcraft_dxf::Tag::i(71, 2));
|
||||
set(73, cadcraft_dxf::Tag::i(73, 1));
|
||||
set(42, cadcraft_dxf::Tag::f(42, 175.0));
|
||||
set(71, craft_dxf::Tag::i(71, 2));
|
||||
set(73, craft_dxf::Tag::i(73, 1));
|
||||
set(42, craft_dxf::Tag::f(42, 175.0));
|
||||
let (_, t) = mtext_from_groups(&tags);
|
||||
let c = t.columns.expect("columns");
|
||||
assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3));
|
||||
@@ -392,10 +392,10 @@ fn embedded_column_width_wins_over_the_entitys_width() {
|
||||
// what the R2018 object gets back (#2467 review).
|
||||
let mut tags = embedded_tags(3, 50.0, 12.5, &[20.0, 20.0, 20.0]);
|
||||
let own_41 = tags.iter().position(|t| t.code == 41).unwrap();
|
||||
tags[own_41] = cadcraft_dxf::Tag::f(41, 175.0);
|
||||
tags[own_41] = craft_dxf::Tag::f(41, 175.0);
|
||||
for code in [40, 42] {
|
||||
let at = tags.iter().rposition(|t| t.code == code).unwrap();
|
||||
tags[at] = cadcraft_dxf::Tag::f(code, 175.0);
|
||||
tags[at] = craft_dxf::Tag::f(code, 175.0);
|
||||
}
|
||||
let (_, t) = mtext_from_groups(&tags);
|
||||
assert_eq!(t.width, 50.0, "the column width, not the total");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! RASTERVARIABLES and dictionaries the DXF Reference describes.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Image};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec2;
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
/// A small DXF as another program writes it: a line, an image on layer `Pics` pointing at an
|
||||
/// IMAGEDEF named `logo`, and an MLINE without vertices.
|
||||
@@ -114,7 +114,7 @@ fn images(d: &Drawing) -> Vec<Image> {
|
||||
|
||||
/// The records of a section of a DXF text.
|
||||
fn section(dxf: &str, name: &str) -> Vec<(String, Vec<Tag>)> {
|
||||
let tags = cadcraft_dxf::parse(dxf.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(dxf.as_bytes()).unwrap();
|
||||
let s = sections(&tags).into_iter().find(|s| s.name == name).unwrap();
|
||||
records(&s.tags)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ fn fonts(d: &Drawing) -> Vec<(String, String)> {
|
||||
fn style_files(dxf: &str) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut style = false;
|
||||
for t in cadcraft_dxf::parse(dxf.as_bytes()).unwrap() {
|
||||
for t in craft_dxf::parse(dxf.as_bytes()).unwrap() {
|
||||
match t.code {
|
||||
0 => style = t.str() == "STYLE",
|
||||
2 if style => out.push((t.str(), String::new())),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! Foreign REGIONs stay unknown objects as before; hostile xdata doesn't panic.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, HatchLoop, Region, Space};
|
||||
use cadcraft_dxf::{records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use craft_dxf::{records, sections};
|
||||
|
||||
fn regions(d: &Drawing) -> Vec<Region> {
|
||||
d.model
|
||||
@@ -56,7 +56,7 @@ fn region_with_islands_survives_save_and_reopen() {
|
||||
assert!(matches!(back.entity(plain).unwrap().kind, EntityKind::LwPolyline(_)));
|
||||
|
||||
// Other readers see four closed polylines on the region's layer.
|
||||
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let ents = records(§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!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
let back = cadcraft_io::read(&bytes, "attributes.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert_eq!(cadcraft_io::save_version(&back), version, "{context}: version");
|
||||
assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes);
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Layer, Lineweight, Transparency};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn fixture(properties: &str) -> Drawing {
|
||||
let records = [
|
||||
@@ -45,7 +45,7 @@ fn output(common: &Common, version: Version, binary: bool) -> Vec<(String, Vec<T
|
||||
drawing.model.modify(handle, |e| e.common = common.clone());
|
||||
}
|
||||
let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§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 = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_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 = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_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 = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_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!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
let back = read(&bytes, "memory.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert_eq!(save_version(&back), version, "{context}: version");
|
||||
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, Layer, TextStyle};
|
||||
use cadcraft_dxf::{Tag, Value, records, sections};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, Value, records, sections};
|
||||
|
||||
fn layers() -> Vec<(Layer, i64)> {
|
||||
let mut cases = Vec::new();
|
||||
@@ -59,7 +59,7 @@ fn fixture(table: &str, body: &str) -> Drawing {
|
||||
|
||||
fn output(drawing: &Drawing, table: &str, version: Version, binary: bool) -> Vec<Tag> {
|
||||
let bytes = write_with(drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let tables = records(§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,9 +4,9 @@
|
||||
//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown};
|
||||
use cadcraft_dxf::{records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{records, sections};
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = super::cases::drawing();
|
||||
@@ -55,7 +55,7 @@ fn preserved(original: &Drawing, back: &Drawing, context: &str) {
|
||||
}
|
||||
|
||||
fn dxf_owner(bytes: &[u8]) {
|
||||
let tags = cadcraft_dxf::parse(bytes).unwrap();
|
||||
let tags = craft_dxf::parse(bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let tables = records(§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!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
dxf_owner(&bytes);
|
||||
} else {
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
/// DXF text from lines of `code value` (the value may be empty).
|
||||
fn dxf(src: &str) -> String {
|
||||
@@ -565,7 +565,7 @@ struct Out {
|
||||
|
||||
impl Out {
|
||||
fn new(text: &str) -> Out {
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let tags = craft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let mut recs = Vec::new();
|
||||
for s in sections(&tags) {
|
||||
for (k, t) in records(&s.tags) {
|
||||
@@ -898,7 +898,7 @@ fn class_records_have_instance_counts_from_2004_only() {
|
||||
// Binary files the same way: none at R2000, the count at 2018.
|
||||
let acme = |version: Version| {
|
||||
let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).expect("save binary");
|
||||
let tags = cadcraft_dxf::parse(&bytes).expect("parse binary");
|
||||
let tags = craft_dxf::parse(&bytes).expect("parse binary");
|
||||
let class = sections(&tags)
|
||||
.into_iter()
|
||||
.filter(|sec| sec.name == "CLASSES")
|
||||
|
||||
@@ -76,8 +76,8 @@ fn multileader_styles_and_current_style_round_trip() {
|
||||
d.mleader_styles.push(callout);
|
||||
d.header.set_str("CMLEADERSTYLE", "Callout");
|
||||
let text = write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let header = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
|
||||
let tags = craft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let header = craft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
|
||||
assert!(header.tags.iter().all(|t| t.code != 9 || t.str() != "$CMLEADERSTYLE"));
|
||||
let r = read_dxf(text.as_bytes()).expect("reopen");
|
||||
assert_eq!(r.mleader_styles, d.mleader_styles);
|
||||
|
||||
@@ -114,7 +114,7 @@ fn entget_shows_plain_carets_and_entmake_takes_them() {
|
||||
// entmod of entget changes nothing; entmake takes a plain string.
|
||||
let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap();
|
||||
assert_eq!(strings(&back), ["x^ y 10^2"]);
|
||||
let made = [cadcraft_dxf::Tag::s(1, "p^ q")];
|
||||
let made = [craft_dxf::Tag::s(1, "p^ q")];
|
||||
assert_eq!(strings(&cadcraft_io::entity_from_groups("TEXT", &made).unwrap().1), ["p^ q"]);
|
||||
// Every kind, and a DIMENSION's style name (also group 3) as it is.
|
||||
let d = drawing();
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, ClipBoundary, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space};
|
||||
use cadcraft_dxf::Version;
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use cadcraft_io::{SaveOptions, read, write_with};
|
||||
use craft_dxf::Version;
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user