Files
cadcraft/crates/io/src/tests.rs
T

2976 lines
138 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
use crate::*;
#[path = "../tests/support/pdf_read.rs"]
mod pdf_read;
fn sample() -> Drawing {
let mut d = Drawing::new_imperial();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
let c = |l: &str| Common { layer: l.into(), ..Common::default() };
d.add(&Space::Model, c("Walls"), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) })).unwrap();
d.add(&Space::Model, c("0"), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.5 })).unwrap();
d.add(&Space::Model, c("0"), EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.5, end: 2.0 })).unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::with_bulge(Vec2::new(4.0, 0.0), 0.5), PolyVertex::new(Vec2::new(4.0, 3.0))],
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Text(Text {
insert: Vec3::new(1.0, 1.0, 0.0),
align_pt: None,
height: 0.25,
value: "Hello Café".into(),
rotation: 0.3,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::MText(MText {
insert: Vec3::new(5.0, 5.0, 0.0),
height: 0.2,
width: 3.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: "line one\\Pline two".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Ellipse(Ellipse {
center: Vec3::new(8.0, 8.0, 0.0),
major: Vec3::new(3.0, 0.0, 0.0),
ratio: 0.5,
start: 0.0,
end: std::f64::consts::TAU,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Spline(cadcraft_geom::Spline::from_fit_points(&[Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 1.0), Vec2::new(4.0, 3.0)])),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Dimension(Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(0.0, -1.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 10.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Hatch(Hatch {
pattern: "ANSI31".into(),
solid: false,
loops: vec![HatchLoop {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::new(Vec2::new(2.0, 0.0)), PolyVertex::new(Vec2::new(2.0, 2.0))],
outer: true,
sources: Vec::new(),
}],
scale: 1.0,
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: None,
origin: Vec2::ZERO,
background: None,
pattern_lines: Vec::new(),
}),
)
.unwrap();
let mut b = Block::new("Bolt");
b.entities.push(Entity::new(Handle(0x50), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.25 })));
d.blocks.insert("Bolt".into(), std::sync::Arc::new(b));
d.add(
&Space::Model,
c("0"),
EntityKind::Insert(Insert {
block: "Bolt".into(),
insert: Vec3::new(2.0, 2.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
.unwrap();
d.add(&Space::Paper("Layout1".into()), c("0"), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) })).unwrap();
d
}
#[test]
fn dxf_roundtrip_preserves_entities() {
let d = sample();
let text = write_dxf(&d);
let back = read_dxf(text.as_bytes()).unwrap();
let kinds = |d: &Drawing| d.model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
assert_eq!(kinds(&back), kinds(&d));
assert_eq!(back.layer("Walls").unwrap().color, Color::Index(1));
assert!(back.block("Bolt").is_some());
assert_eq!(back.layout("Layout1").unwrap().entities.len(), 1);
// Geometry survives.
for (a, b) in d.model.iter().zip(back.model.iter()) {
match (&a.kind, &b.kind) {
(EntityKind::Line(x), EntityKind::Line(y)) => assert_eq!(x, y),
(EntityKind::Circle(x), EntityKind::Circle(y)) => assert_eq!(x, y),
(EntityKind::LwPolyline(x), EntityKind::LwPolyline(y)) => assert_eq!(x.vertices, y.vertices),
(EntityKind::Text(x), EntityKind::Text(y)) => {
assert_eq!(x.value, y.value);
assert!((x.rotation - y.rotation).abs() < 1e-9);
}
(EntityKind::MText(x), EntityKind::MText(y)) => assert_eq!(x.contents, y.contents),
(EntityKind::Hatch(x), EntityKind::Hatch(y)) => assert_eq!(x.loops, y.loops),
_ => {}
}
assert_eq!(a.handle, b.handle);
assert_eq!(a.common.layer, b.common.layer);
}
// Handles stay unique after reading.
let mut back = back;
let h = back.new_handle();
assert!(back.entity(h).is_none());
}
#[test]
fn lwpolyline_elevation_roundtrips() {
for elevation in [5.0, -2.5, 0.0] {
let mut d = Drawing::new_metric();
d.add(
&Space::Model,
Common::default(),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::new(Vec2::new(10.0, 0.0)), PolyVertex::new(Vec2::new(10.0, 10.0))],
closed: true,
const_width: 0.0,
elevation,
plinegen: false,
}),
)
.unwrap();
let back = roundtrip(&d);
let again = roundtrip(&back);
for doc in [&back, &again] {
let p = first(doc, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert_eq!(p.elevation, elevation);
assert_eq!(p.vertices.len(), 3);
}
}
}
#[test]
fn second_roundtrip_is_stable() {
let d = sample();
let t1 = write_dxf(&d);
let d2 = read_dxf(t1.as_bytes()).unwrap();
let d3 = read_dxf(write_dxf(&d2).as_bytes()).unwrap();
assert_eq!(d2.model.len(), d3.model.len());
}
#[test]
fn layer_transparency_and_description_roundtrip() {
// Issues #96 and #97: both are LAYER xdata (AcCmTransparency, AcAecLayerStandard).
let layers = [("T0", 0, ""), ("T1", 1, "One"), ("T40", 40, "Existing brick walls to remain"), ("T90", 90, "Ångström – ünïcode")];
let mut d = Drawing::new_imperial();
for (name, transparency, description) in layers {
d.layers.push(Layer {
name: name.into(),
transparency,
description: description.into(),
color: Color::Index(1),
plot: false,
..Layer::default()
});
}
let long = "é".repeat(200);
d.layers.push(Layer { name: "Long".into(), description: long.clone(), ..Layer::default() });
let text = write_dxf(&d);
let back = read_dxf(text.as_bytes()).unwrap();
for (name, transparency, description) in layers {
let l = back.layer(name).unwrap();
assert_eq!((l.transparency, l.description.as_str(), l.color, l.plot), (transparency, description, Color::Index(1), false), "{name}");
}
// An xdata string holds at most 255 bytes; the cut falls on a character boundary.
assert_eq!(back.layer("Long").unwrap().description, long[..254]);
// AutoCAD's encoding: the alpha with the "by alpha" flag, under registered applications.
assert!(text.contains("AcCmTransparency\r\n1071\r\n33554585\r\n"), "40% is alpha 153");
assert_eq!(text.matches("AcDbRegAppTableRecord\r\n 2\r\nAcCmTransparency").count(), 1);
assert!(text.contains("AcDbRegAppTableRecord\r\n 2\r\nAcAecLayerStandard"));
// Values that aren't "by alpha" (ByLayer, ByBlock, junk) leave the layer opaque.
for v in [0, 0x0100_0000, -1, i64::MAX] {
assert_eq!(crate::dxf_ext::transparency_from_dxf(v), None, "{v:#x}");
}
assert_eq!(crate::dxf_ext::transparency_from_dxf(0x0200_00ff), Some(0));
assert_eq!(crate::dxf_ext::transparency_from_dxf(0x0200_0000), Some(90));
}
#[test]
fn block_definition_properties_and_constant_attdefs_roundtrip() {
let mut d = Drawing::new_metric();
let mut b = Block::new("Title");
(b.description, b.units, b.explodable) = ("Drawing title block".into(), 4, false);
let text = Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.5,
value: "ACME".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
};
let attdef = |tag: &str, invisible: bool, constant: bool| Attrib {
tag: tag.into(),
text: text.clone(),
invisible,
constant,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
for (i, a) in [attdef("COMPANY", false, true), attdef("SECRET", true, true), attdef("SHEET", false, false)].into_iter().enumerate() {
b.entities.push(Entity::new(Handle(0x500 + i as u64), EntityKind::AttDef(a)));
}
d.blocks.insert("Title".into(), std::sync::Arc::new(b));
d.blocks.insert("Plain".into(), std::sync::Arc::new(Block::new("Plain")));
d.bump_handseed(Handle(0x510));
let back = roundtrip(&d);
let t = back.block("Title").unwrap();
assert_eq!((t.description.as_str(), t.units, t.explodable), ("Drawing title block", 4, false));
let flags: Vec<(String, bool, bool)> = t
.entities
.iter()
.filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some((a.tag.clone(), a.invisible, a.constant)) } else { None })
.collect();
assert_eq!(flags, [("COMPANY".into(), false, true), ("SECRET".into(), true, true), ("SHEET".into(), false, false)]);
let p = back.block("Plain").unwrap();
assert_eq!((p.description.as_str(), p.units, p.explodable), ("", 0, true));
}
#[test]
fn reads_r12_style_polyline_and_paper_flag() {
let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n1\n0\nVERTEX\n8\n0\n10\n0\n20\n0\n0\nVERTEX\n8\n0\n10\n5\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n5\n20\n5\n0\nSEQEND\n0\nLINE\n67\n1\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "a.dxf").unwrap();
assert_eq!(d.model.len(), 1);
match &d.model.iter().next().unwrap().kind {
EntityKind::LwPolyline(p) => {
assert_eq!(p.vertices.len(), 3);
assert!(p.closed);
assert_eq!(p.vertices[1].bulge, 1.0);
}
_ => panic!(),
}
assert_eq!(d.layouts[0].entities.len(), 1);
}
#[test]
fn entity_transparency_roundtrips_as_group_440() {
// Issue #132: ByLayer writes nothing, ByBlock 0x01000000, a percentage its alpha | 0x02000000.
let ts = [Transparency::ByLayer, Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)];
let mut d = Drawing::new_imperial();
for (i, t) in ts.iter().enumerate() {
let y = i as f64;
let line = EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(1.0, y, 0.0) });
d.add(&Space::Model, Common { transparency: *t, ..Common::default() }, line).unwrap();
}
let text = write_dxf(&d);
assert_eq!(text.matches("\r\n440\r\n").count(), 4, "ByLayer writes no 440");
assert!(text.contains("\r\n440\r\n16777216\r\n"), "ByBlock");
assert!(text.contains("\r\n440\r\n33554585\r\n"), "40% is alpha 153");
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), ts);
// Values that are neither ByBlock nor "by alpha" read as ByLayer.
let text = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n440\n-1\n10\n0\n20\n0\n11\n1\n21\n1\n0\nLINE\n8\n0\n440\n33554432\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "a.dxf").unwrap();
assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), [Transparency::ByLayer, Transparency::Percent(90)]);
}
/// Several SORTENTSTABLEs for one space sort it once, in the last table's order.
#[test]
fn repeated_sortents_tables_use_the_last() {
let table =
|a: u32, b: u32| format!("0\nSORTENTSTABLE\n5\n{:X}\n100\nAcDbSortentsTable\n330\n1F\n331\nA\n5\n{a:X}\n331\nB\n5\n{b:X}\n", 0x100 + a);
let mut t = String::from(
"0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n0\nBLOCK_RECORD\n5\n1F\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nLINE\n5\nA\n330\n1F\n10\n0\n20\n0\n11\n1\n21\n1\n0\nLINE\n5\nB\n330\n1F\n10\n0\n20\n0\n11\n2\n21\n2\n0\nENDSEC\n\
0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n",
);
for _ in 0..1000 {
t.push_str(&table(2, 1));
}
t.push_str(&table(1, 2));
t.push_str("0\nENDSEC\n0\nEOF\n");
let d = read(t.as_bytes(), "s.dxf").unwrap();
assert_eq!(d.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), [0xA, 0xB]);
let reversed = t.replacen(&table(1, 2), &table(2, 1), 1);
let d = read(reversed.as_bytes(), "s.dxf").unwrap();
assert_eq!(d.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), [0xB, 0xA]);
}
/// A table declaring 10 000 rows and no columns (or the reverse) is read as an empty table.
#[test]
fn table_with_an_empty_dimension_allocates_nothing() {
for (rows, cols) in [(10_000, 0), (0, 10_000)] {
let t = format!("0\nSECTION\n2\nENTITIES\n0\nACAD_TABLE\n100\nAcDbTable\n91\n{rows}\n92\n{cols}\n0\nENDSEC\n0\nEOF\n");
let d = read(t.as_bytes(), "t.dxf").unwrap();
let tables: Vec<_> = d.model.iter().filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t) } else { None }).collect();
assert_eq!(tables.len(), 1, "the table is read");
for t in tables {
assert!(t.row_heights.is_empty() && t.col_widths.is_empty() && t.cells.is_empty());
}
}
}
/// Many distinct layer, linetype and style names (in the tables and on entities) read in linear
/// time, with the duplicate rules kept: first LTYPE wins, last STYLE wins, layers case-insensitive.
#[test]
fn many_distinct_symbol_names_read_quickly() {
const N: usize = 40_000;
let mut t = String::from("0\nSECTION\n2\nTABLES\n");
for i in 0..N {
t.push_str(&format!("0\nLTYPE\n2\nLT{i}\n3\nfirst\n0\nSTYLE\n2\nST{i}\n40\n1\n"));
}
t.push_str("0\nLTYPE\n2\nlt0\n3\nsecond\n0\nSTYLE\n2\nst0\n40\n2\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n");
for i in 0..N {
t.push_str(&format!("0\nLINE\n8\nL{i}\n10\n0\n20\n0\n11\n1\n21\n1\n"));
}
t.push_str("0\nLINE\n8\nl0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n");
let started = std::time::Instant::now();
let d = read(t.as_bytes(), "many.dxf").unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(20), "took {:?}", started.elapsed());
assert!(d.layers.len() > N && d.layers.len() < N + 3, "{}", d.layers.len());
assert_eq!(d.linetype("LT0").map(|l| l.description.as_str()), Some("first"));
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
}
#[test]
fn hostile_dxf_does_not_panic() {
for t in [
"0\nSECTION\n2\nENTITIES\n0\nHATCH\n91\n999999999\n92\n2\n93\n99999\n0\nENDSEC\n0\nEOF\n",
"0\nSECTION\n2\nENTITIES\n0\nSPLINE\n71\n99\n40\n1\n10\n0\n20\n0\n0\nENDSEC\n0\nEOF\n",
"0\nSECTION\n2\nENTITIES\n0\nINSERT\n66\n1\n2\nX\n0\nATTRIB\n0\nENDSEC\n",
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n0\nINSERT\n2\nA\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
"0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n42\n5\n20\n1\n0\nENDSEC\n0\nEOF\n",
] {
if let Ok(d) = read(t.as_bytes(), "x.dxf") {
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let _ = d.extents(&Space::Model);
}
}
}
#[test]
fn svg_and_png_export() {
let d = sample();
let svg = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap();
assert!(svg.starts_with("<svg") && svg.contains("polyline"));
let png = write(&d, "a.png").unwrap();
assert_eq!(&png[1..4], b"PNG");
}
fn view_box(svg: &str) -> String {
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
svg[i..].split('"').next().unwrap_or("").to_string()
}
#[test]
fn image_export_frames_window() {
use cadcraft_geom::Bounds2;
let d = sample();
let win = Bounds2::new(Vec2::new(1.0, 2.0), Vec2::new(31.0, 22.0));
// SVG: the viewBox is exactly the window (no margin); the default still fits the extents.
let framed = String::from_utf8(write_framed(&d, "a.svg", Some(win)).unwrap()).unwrap();
let plain = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap();
assert_eq!(view_box(&framed), "0 0 30.000000 20.000000");
assert_ne!(view_box(&framed), view_box(&plain));
assert_eq!(plain, String::from_utf8(write_framed(&d, "a.svg", None).unwrap()).unwrap());
let ext = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
let m = ext.width().max(ext.height()) * 0.02;
assert_eq!(view_box(&plain), format!("0 0 {:.6} {:.6}", ext.width() + 2.0 * m, ext.height() + 2.0 * m));
// PNG: the window fills the 3:2 image exactly, centred.
let v = png_view(&ext, 2400, 1600, Some(win));
assert_eq!(v.center, Vec2::new(16.0, 12.0));
assert!((v.scale - 80.0).abs() < 1e-9);
assert_eq!(png_view(&ext, 2400, 1600, None), cadcraft_render::raster::View::fit(&ext, 2400, 1600, 0.05));
let png_framed = write_framed(&d, "a.png", Some(win)).unwrap();
assert_eq!(&png_framed[1..4], b"PNG");
assert_ne!(png_framed, write(&d, "a.png").unwrap());
// PDF: the window is fitted to the sheet instead of the extents.
let pdf_framed = write_framed(&d, "a.pdf", Some(win)).unwrap();
check_pdf(&pdf_framed);
assert_ne!(pdf_framed, write(&d, "a.pdf").unwrap());
// Non-image formats ignore the window.
assert_eq!(write_framed(&d, "a.dxf", Some(win)).unwrap(), write(&d, "a.dxf").unwrap());
}
#[test]
fn hostile_export_window_is_an_error() {
use cadcraft_geom::Bounds2;
let d = sample();
let raw = |x0: f64, y0: f64, x1: f64, y1: f64| Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
let bad = [
raw(f64::NAN, 0.0, 1.0, 1.0),
raw(0.0, 0.0, f64::INFINITY, 1.0),
raw(0.0, 0.0, 0.0, 1.0), // zero width
raw(0.0, 0.0, 1.0, 0.0), // zero height
raw(5.0, 0.0, 1.0, 1.0), // inverted
raw(-1e300, -1e300, 1e300, 1e300), // huge
raw(1e11, 1e11, 1e11 + 1e-6, 1e11 + 1e-6), // vanishingly small next to its coordinates
Bounds2::EMPTY,
];
for w in bad {
assert!(check_window(&w).is_err(), "{w:?}");
for name in ["a.png", "a.svg", "a.pdf"] {
assert!(write_framed(&d, name, Some(w)).is_err(), "{name} {w:?}");
}
}
assert!(check_window(&raw(-1e6, -1e6, 1e6, 1e6)).is_ok());
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
}
/// Read a PDF back (its structure checked by the reader) and return the first page's decoded
/// content.
fn check_pdf(bytes: &[u8]) -> String {
assert!(String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).trim_end().ends_with("%%EOF"));
let doc = pdf_read::read(bytes);
assert!(!doc.pages.is_empty());
doc.content().to_string()
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
pdf_read::read(bytes).media()
}
/// The line widths (`w`) the content sets.
fn widths(content: &str) -> Vec<f64> {
pdf_read::ops(content).iter().filter(|o| o.op == "w").filter_map(|o| o.nums.first().copied()).collect()
}
#[test]
fn pdf_model_fitted_to_sheet() {
let d = sample();
let bytes = plot(&d, &Space::Model, &serde_json::json!({"paper": "A4", "landscape": true})).unwrap();
let content = check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
assert!(pdf_read::count(&content, "m") > 0 && pdf_read::count(&content, "l") > 0 && pdf_read::count(&content, "S") > 0);
// Walls layer is red; colour 7 prints black.
assert!(content.contains("1 0 0 RG"));
assert!(content.contains("0 0 0 RG"));
// Uncompressed output is plain text and also valid.
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
let c2 = check_pdf(&raw);
assert!(!pdf_read::read(&raw).pages[0].compressed);
assert!(pdf_read::read(&bytes).pages[0].compressed);
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(pdf_read::clip_rect(&c2).is_some(), "clipped to the plot area: {c2}");
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
#[test]
fn pdf_layout_one_to_one_with_viewport() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
d.layouts[0].page.lineweights = true;
d.add(
&paper,
Common::default(),
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id: 2,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}),
)
.unwrap();
let bytes = plot(&d, &paper, &serde_json::json!({})).unwrap();
let content = check_pdf(&bytes);
// ANSI A landscape (inches → points).
assert_eq!(media_box(&bytes), (792.0, 612.0));
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
let corner = |o: &pdf_read::Op| matches!(o.op.as_str(), "m" | "l") && (o.nums[0] - 72.0).abs() < 1e-6 && (o.nums[1] - 72.0).abs() < 1e-6;
assert!(pdf_read::drawn(&content).iter().any(corner), "{content}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(widths(&content).iter().any(|w| (w - 0.709).abs() < 0.001), "{content}");
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(widths(&check_pdf(&off)).contains(&0.0));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
#[test]
fn pdf_hostile_options_and_empty() {
let d = Drawing::new_metric();
let bytes = plot(&d, &Space::Model, &serde_json::json!({})).unwrap();
check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "metric default is A4 landscape");
let s = sample();
for o in [
serde_json::json!({"width": 1e308, "height": -5}),
serde_json::json!({"width": 1e308, "height": 1e308, "scale": 1e308, "fit": false}),
serde_json::json!({"scale": -1, "fit": false, "title": "a(b)\\c\u{e9}"}),
serde_json::json!(null),
serde_json::json!([1, 2]),
] {
check_pdf(&plot(&s, &Space::Model, &o).unwrap());
}
}
#[test]
fn dxf_roundtrips_page_setup() {
let mut d = sample();
let a3 = cadcraft_render::paper_size("A3").unwrap();
{
let p = &mut d.layouts[0].page;
p.paper = a3.name.into();
p.width_mm = a3.width_mm;
p.height_mm = a3.height_mm;
p.landscape = false;
p.margins_mm = [5.0, 6.0, 7.0, 8.0];
}
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
let p = &back.layout("Layout1").unwrap().page;
assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, false));
assert_eq!(p.margins_mm, [5.0, 6.0, 7.0, 8.0]);
assert_eq!(p.paper, a3.name);
}
// ---------------------------------------------------------------------------------------------
// Round trips of styles, overrides, associativity, constraints and tables.
fn dim(kind: DimKind, p13: Vec3, p14: Vec3) -> Dimension {
Dimension {
kind,
defpt: Vec3::new(p13.x, p13.y - 2.0, 0.0),
text_mid: Vec3::ZERO,
p13,
p14,
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
}
}
fn first<T>(d: &Drawing, f: impl Fn(&EntityKind) -> Option<T>) -> T {
d.model.iter().find_map(|e| f(&e.kind)).expect("entity")
}
fn roundtrip(d: &Drawing) -> Drawing {
read_dxf(write_dxf(d).as_bytes()).unwrap()
}
#[test]
fn viewport_frozen_layers_roundtrip() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
let vp = |id: u32, frozen: Vec<String>| {
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id,
locked: true,
frozen_layers: frozen,
layer_colors: Vec::new(),
clip: None,
on: true,
})
};
d.add(&paper, Common::default(), vp(2, vec!["Walls".into(), "A B".into()])).unwrap();
d.add(&paper, Common::default(), vp(3, Vec::new())).unwrap();
let back = roundtrip(&d);
let frozen: Vec<(u32, Vec<String>)> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) => Some((v.id, v.frozen_layers.clone())),
_ => None,
})
.collect();
assert!(frozen.contains(&(2, vec!["Walls".to_string(), "A B".to_string()])), "{frozen:?}");
assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}");
}
/// `d` as an R2018 DXF (frozen viewport layers in group 331).
fn dxf_2018(d: &Drawing) -> String {
let opts = SaveOptions { version: Some(Version::R2018), binary: false };
String::from_utf8(write_with(d, "t.dxf", &opts).unwrap()).unwrap()
}
/// A layout with viewport 2 frozen on `Walls` and `A B` (both real layers), and its written DXF as
/// (code, value) pairs.
fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) {
let mut d = sample();
d.layers.push(Layer { name: "A B".into(), ..Layer::default() });
let paper = Space::Paper("Layout1".into());
let vp = EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id: 2,
locked: false,
frozen_layers: vec!["Walls".into(), "A B".into()],
layer_colors: Vec::new(),
clip: None,
on: true,
});
d.add(&paper, Common::default(), vp).unwrap();
let text = dxf_2018(&d);
(d, pairs(&text))
}
/// The (code, value) pairs of DXF text.
fn pairs(text: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = text.lines().collect();
lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect()
}
/// The pair range of the VIEWPORT record whose group 69 is `id`.
fn vp_record(p: &[(String, String)], id: &str) -> std::ops::Range<usize> {
let starts: Vec<usize> = (0..p.len()).filter(|&i| p[i].0 == "0").collect();
for w in starts.windows(2) {
let r = w[0]..w[1];
if p[w[0]].1 == "VIEWPORT" && p[r.clone()].iter().any(|(c, v)| c == "69" && v.trim() == id) {
return r;
}
}
panic!("no VIEWPORT {id}");
}
fn layer_handle(p: &[(String, String)], name: &str) -> String {
let starts: Vec<usize> = (0..p.len()).filter(|&i| p[i].0 == "0").collect();
for w in starts.windows(2) {
let rec = &p[w[0]..w[1]];
if rec[0].1 == "LAYER" && rec.iter().any(|(c, v)| c == "2" && v == name) {
return rec.iter().find(|(c, _)| c == "5").unwrap().1.clone();
}
}
panic!("no LAYER {name}");
}
fn viewport_frozen(d: &Drawing, id: u32) -> Vec<String> {
d.layouts
.iter()
.flat_map(|l| l.entities.iter())
.find_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.id == id => Some(v.frozen_layers.clone()),
_ => None,
})
.expect("viewport")
}
fn join(p: &[(String, String)]) -> String {
p.iter()
.map(|(c, v)| {
format!(
"{c}
{v}
"
)
})
.collect()
}
/// The group 331 values of viewport 2's record.
fn frozen_331(p: &[(String, String)]) -> Vec<String> {
p[vp_record(p, "2")].iter().filter(|(c, _)| c == "331").map(|(_, v)| v.trim().to_string()).collect()
}
#[test]
fn viewport_frozen_layers_written_as_331_handles() {
// Real layers go out as handles only, before group 90.
let (_, p) = frozen_vp_dxf();
assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]);
let rec = &p[vp_record(&p, "2")];
assert!(!rec.iter().any(|(c, v)| c == "1000" && v == "VPFROZEN"), "no xdata for layers with a handle");
assert!(rec.iter().position(|(c, _)| c == "331") < rec.iter().position(|(c, _)| c == "90"));
// An R2000 DXF holds them in group 341, as the DXF 2000 Reference has them.
let (d, _) = frozen_vp_dxf();
let p = pairs(&write_dxf(&d));
let h341: Vec<String> = p[vp_record(&p, "2")].iter().filter(|(c, _)| c == "341").map(|(_, v)| v.clone()).collect();
assert_eq!(h341, [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]);
assert!(frozen_331(&p).is_empty());
}
#[test]
fn viewport_frozen_layers_read_from_331_or_341_once_each() {
let (_, p) = frozen_vp_dxf();
let r = vp_record(&p, "2");
// 341 instead of 331, then Walls again in 331 and a handle that names no layer.
let mut q = p.clone();
for t in &mut q[r.clone()] {
if t.0 == "331" {
t.0 = "341".into();
}
}
q.splice(r.end..r.end, [("331".into(), "FFFFF".into()), ("331".into(), layer_handle(&p, "Walls"))]);
// A 331 inside another application's `102 {…}` group isn't a frozen layer.
let app = [("102".into(), "{ACAD_OTHER".into()), ("331".into(), layer_handle(&p, "0")), ("102".into(), "}".into())];
q.splice(r.start + 2..r.start + 2, app);
let back = read_dxf(join(&q).as_bytes()).unwrap();
assert_eq!(viewport_frozen(&back, 2), ["Walls", "A B"]);
}
#[test]
fn viewport_freeze_edited_in_autocad_is_read_as_edited() {
// Every layer thawed in AutoCAD (no 331 left) stays thawed; a layer it froze instead is read.
let (_, p) = frozen_vp_dxf();
let r = vp_record(&p, "2");
let thawed: Vec<(String, String)> = p.iter().enumerate().filter(|(i, (c, _))| !(r.contains(i) && c == "331")).map(|(_, t)| t.clone()).collect();
assert!(viewport_frozen(&read_dxf(join(&thawed).as_bytes()).unwrap(), 2).is_empty());
let mut zero = p.clone();
for t in &mut zero[r] {
if t.0 == "331" {
t.1 = layer_handle(&p, "0");
}
}
assert_eq!(viewport_frozen(&read_dxf(join(&zero).as_bytes()).unwrap(), 2), ["0"]);
}
#[test]
fn viewport_frozen_layers_survive_dwg() {
let (d, _) = frozen_vp_dxf();
let dwg = write(&d, "t.dwg").unwrap();
let back = read(&dwg, "t.dwg").unwrap();
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
// The DWG holds them in group 331, what AutoCAD reads.
let p = pairs(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap());
assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]);
}
#[test]
fn viewport_layer_colors_roundtrip() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
let vp = |id: u32, frozen: Vec<String>, colors: Vec<(String, Color)>| {
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id,
locked: false,
frozen_layers: frozen,
layer_colors: colors,
clip: None,
on: true,
})
};
let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))];
d.add(&paper, Common::default(), vp(2, Vec::new(), colors.clone())).unwrap();
d.add(&paper, Common::default(), vp(3, Vec::new(), Vec::new())).unwrap();
// Frozen layers and colour overrides share one CADCraft xdata group.
d.add(&paper, Common::default(), vp(4, vec!["Walls".into()], colors.clone())).unwrap();
let back = roundtrip(&d);
let found: Vec<(u32, Vec<String>, Vec<(String, Color)>)> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) => Some((v.id, v.frozen_layers.clone(), v.layer_colors.clone())),
_ => None,
})
.collect();
assert!(found.contains(&(2, Vec::new(), colors.clone())), "{found:?}");
assert!(found.contains(&(3, Vec::new(), Vec::new())), "{found:?}");
assert!(found.contains(&(4, vec!["Walls".to_string()], colors)), "{found:?}");
}
fn full_dim_style() -> DimStyle {
DimStyle {
name: "Mech".into(),
scale: 2.5,
arrow_size: 0.25,
ext_offset: 0.1,
ext_extend: 0.2,
text_height: 0.3,
text_gap: 0.05,
decimals: 3,
angular_decimals: 2,
linear_factor: 0.5,
text_above: 1,
text_inside_horizontal: false,
text_outside_horizontal: false,
arrow_block: "_ArchTick".into(),
tick_size: 0.15,
dim_line_color: Color::Index(1),
ext_line_color: Color::ByLayer,
text_color: Color::Index(5),
text_style: "Romans".into(),
post: "<> mm".into(),
center_mark: -0.1,
zero_suppression: 12,
linear_unit: 4,
tolerance: true,
tol_plus: 0.02,
tol_minus: 0.01,
baseline_spacing: 0.5,
annotative: true,
arrow_block1: "_DOT".into(),
arrow_block2: "_Open".into(),
dim_line_extend: 0.125,
text_just: 2,
round: 0.25,
decimal_separator: ",".into(),
fraction_format: 1,
limits: true,
tol_decimals: 2,
tol_scale: 0.75,
tol_zero_suppression: 8,
alt: true,
alt_factor: 0.03937,
alt_decimals: 3,
alt_post: "[<>]".into(),
alt_round: 0.005,
alt_unit: 6,
alt_zero_suppression: 4,
angular_unit: 1,
suppress_ext1: true,
suppress_ext2: true,
..DimStyle::default()
}
}
#[test]
fn dimstyle_roundtrips_every_field() {
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
d.dim_styles.push(full_dim_style());
let text = write_dxf(&d);
// Arrowheads are blocks referenced by handle (342/343/344) with DIMSAH set.
assert!(text.contains("_ArchTick") && text.contains("_DOT") && text.contains("_Open"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Mech").unwrap(), &full_dim_style());
assert_eq!(back.dim_style("Standard").unwrap(), &DimStyle::default());
// Generated arrowhead blocks are not imported as user blocks, so they never pile up.
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{:?}", back.blocks.keys().collect::<Vec<_>>());
let again = roundtrip(&back);
assert_eq!(again.dim_styles, back.dim_styles);
assert_eq!(write_dxf(&again).matches("AcDbBlockBegin").count(), write_dxf(&back).matches("AcDbBlockBegin").count());
}
#[test]
fn user_block_arrowheads_are_referenced_not_regenerated() {
let mut d = Drawing::new_imperial();
let mut b = Block::new("MyArrow");
b.entities.push(Entity::new(Handle(0x60), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.5 })));
d.blocks.insert("MyArrow".into(), std::sync::Arc::new(b));
d.dim_styles.push(DimStyle { name: "U".into(), arrow_block: "MyArrow".into(), ..DimStyle::default() });
let back = roundtrip(&d);
assert_eq!(back.dim_style("U").unwrap().arrow_block, "MyArrow");
assert!(back.block("MyArrow").is_some());
assert_eq!(back.blocks.len(), d.blocks.len());
}
/// Every standard arrowhead CADCraft generates reads back as its own geometry, through DXF and
/// DWG, so reopening a drawing CADCraft wrote never turns them into user blocks.
#[test]
fn generated_arrowhead_blocks_are_not_kept_as_user_blocks() {
let mut d = Drawing::new_metric();
for (i, (_, name)) in crate::dxf_ext::ARROWS.iter().enumerate() {
d.dim_styles.push(DimStyle { name: format!("S{i}"), arrow_block: (*name).into(), ..DimStyle::default() });
}
let dxf = write_dxf(&d);
for (_, name) in crate::dxf_ext::ARROWS.iter().filter(|(k, _)| *k != cadcraft_render::Arrowhead::ClosedFilled) {
assert!(dxf.contains(&format!("\r\n{name}\r\n")), "{name} is written");
}
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{what}: {:?}", back.blocks.keys().collect::<Vec<_>>());
assert!(roundtrip(&back).blocks.keys().all(|k| !k.starts_with('_')), "{what}: second save");
}
}
/// A file's own arrowhead block with a standard name (AutoCAD's `_Dot` is a closed polyline
/// with width, not CADCraft's disc of solids) is kept when only a dimension style names it, and
/// saved as it was, through DXF and DWG, instead of being replaced by CADCraft's geometry.
#[test]
fn file_arrowhead_blocks_with_standard_names_are_kept() {
let mut d = Drawing::new_metric();
let donut = LwPolyline {
vertices: vec![
PolyVertex { p: Vec2::new(-0.25, 0.0), bulge: 1.0, start_width: 0.5, end_width: 0.5 },
PolyVertex { p: Vec2::new(0.25, 0.0), bulge: 1.0, start_width: 0.5, end_width: 0.5 },
],
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
};
for (name, kind) in [("_Dot", EntityKind::LwPolyline(donut)), ("_DotSmall", EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.0625 }))] {
let mut b = Block::new(name);
b.entities.push(Entity::new(Handle(0), kind));
d.blocks.insert(name.into(), std::sync::Arc::new(b));
}
d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: "_Dot".into(), arrow_block2: "_DotSmall".into(), ..DimStyle::default() });
let kinds = |d: &Drawing, name: &str| d.block(name).map(|b| b.entities.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()).unwrap_or_default();
let dxf = write_dxf(&d);
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] {
for name in ["_Dot", "_DotSmall"] {
assert_eq!(kinds(&back, name), kinds(&d, name), "{what}: {name}");
}
let again = write_dxf(&back);
assert!(!again.contains("\nSOLID\r"), "{what}: no arrowhead geometry of CADCraft's is written");
assert_eq!(kinds(&roundtrip(&back), "_Dot"), kinds(&d, "_Dot"), "{what}: a second save keeps it too");
}
}
#[test]
fn dimension_overrides_roundtrip_as_dstyle_xdata() {
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
let raw = serde_json::json!({
"arrowSize": 0.5,
"dimLineColor": 3,
"textStyle": "Romans",
"arrowBlock1": "_Dot",
"arrowBlock": "",
"decimalSeparator": ",",
"suppressExt1": true,
"post": "<> mm",
"decimals": 2,
"textJust": 1,
"altFactor": 25.4,
});
dm.overrides = raw.as_object().unwrap().clone();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("DSTYLE"));
let back = read_dxf(text.as_bytes()).unwrap();
let got = first(&back, |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
// Values come back canonical (colours as colour objects); the effective style is identical.
assert_eq!(got.overrides.len(), dm.overrides.len(), "{:?}", got.overrides);
let base = DimStyle::default();
assert_eq!(base.with_overrides(&got.overrides), base.with_overrides(&dm.overrides));
assert_eq!(got.overrides.get("dimLineColor"), Some(&serde_json::to_value(Color::Index(3)).unwrap()));
// Canonical values round-trip exactly.
let again = first(&roundtrip(&back), |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
assert_eq!(again.overrides, got.overrides);
}
/// A drawing with `n` lines, circles and polylines on a grid (issue #57's tests).
fn many_entities(n: usize) -> Drawing {
let mut d = Drawing::new_imperial();
for i in 0..n {
let (x, y) = ((i % 300) as f64 * 10.0, (i / 300) as f64 * 10.0);
let kind = match i % 3 {
0 => EntityKind::Line(Line { a: Vec3::new(x, y, 0.0), b: Vec3::new(x + 4.0, y + (i % 7) as f64, 0.0) }),
1 => EntityKind::Circle(Circle { center: Vec3::new(x, y, 0.0), radius: 1.0 + (i % 5) as f64 }),
_ => EntityKind::LwPolyline(LwPolyline {
vertices: (0..8).map(|k| PolyVertex::new(Vec2::new(x + f64::from(k), y + (k % 3) as f64))).collect(),
closed: false,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
};
d.add(&Space::Model, Common::default(), kind).unwrap();
}
d
}
/// An AutoCAD 2010 (AC1024) DWG of `n` entities, made through the DWG bridge.
#[cfg(not(target_arch = "wasm32"))]
fn ac1024_dwg(n: usize) -> Vec<u8> {
let dxf = write_dxf(&many_entities(n)).replacen("AC1015", "AC1024", 1);
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1024"));
dwg
}
/// Issue #57: an AutoCAD 2010 DWG with tens of thousands of entities opens completely, and its
/// DXF rendition stays far inside the limits (about 6 bytes of DXF and 0.4 group codes per DWG
/// byte), so a 10 MB file is nowhere near them.
#[cfg(not(target_arch = "wasm32"))]
#[test]
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();
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();
assert_eq!(d.entity_count(), n);
}
/// Issue #57: oversized input is rejected early, with a message naming the limit, never a panic.
/// A conversion that runs away (a damaged or misread DWG can make the reader produce a drawing
/// thousands of times larger than the file) stops at the DXF size limit instead of filling memory
/// and failing later with "file too large"; the DWG size is checked before reading; the DXF
/// group-code count before parsing.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn oversized_dwg_and_dxf_are_rejected_early() {
let dwg = ac1024_dwg(3000);
let full = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().len();
assert!(full > 400_000, "{full}");
let limits = cadcraft_dwg::Limits { max_dxf_bytes: 300_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: converting this 0."), "{e}");
assert!(e.contains("MB file produced more than 0.3 MB of drawing data (the limit)"), "{e}");
let limits = cadcraft_dwg::Limits { max_dwg_bytes: 100_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB CADCraft opens"), "{e}");
// Lines that aren't even group codes: the size is reported before anything is parsed.
let text = "not a group code\nx\n".repeat(60);
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
}
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
/// rest opens, and the drawing's read notes say how many records were left out and of which
/// types, so a partial read never passes for a complete one. A complete read has no notes.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn damaged_dwg_opens_partially_and_says_what_it_left_out() {
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
for i in 0..200 {
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
}
dxf += "0\nENDSEC\n0\nEOF\n";
let d = read(dxf.as_bytes(), "lines.dxf").unwrap();
let dwg = crate::write_with(&d, "lines.dwg", &crate::SaveOptions { version: Some(crate::Version::R2000), binary: false }).unwrap();
let whole = read(&dwg, "lines.dwg").unwrap();
assert_eq!((whole.entity_count(), whole.read_notes.len()), (200, 0));
// Damage a few bytes at a time across the objects area: every damaged copy that still reads
// as a DWG either reads whole or notes what it left out.
let mut partial = 0;
for at in (dwg.len() / 4..dwg.len() * 3 / 5).step_by(97) {
let mut b = dwg.clone();
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
let Ok(d) = read(&b, "damaged.dwg") else { continue };
if d.entity_count() < 200 {
assert_eq!(d.read_notes.len(), 1, "{} lines read, no note", d.entity_count());
}
if let [note] = d.read_notes.as_slice() {
assert!(note.starts_with("DWG: ") && note.contains(" couldn't be read and "), "{note}");
assert!(note.ends_with("saving over the original file would lose it") || note.ends_with("would lose them"), "{note}");
partial += 1;
}
}
assert!(partial > 0, "no damaged copy read partially");
}
/// A dimension's standard DIMASSOC objects carry the same associations as CADCraft's xdata, so
/// a file from another writer (xdata stripped) still follows its geometry: a linear dimension's
/// two origins, a polyline's vertex named by its segment (#1049), and since #1651 a radial
/// dimension's circle, whose centre comes back as a `Center` link and its rim point as
/// `OnCircle`.
#[test]
fn dimension_associativity_roundtrips() {
let mut d = Drawing::new_imperial();
let c = Common::default;
let l1 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(0.0, 5.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(10.0, 0.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) })).unwrap();
let ci = d.add(&Space::Model, c(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0 })).unwrap();
let pl = d
.add(
&Space::Model,
c(),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::new(30.0, 0.0)), PolyVertex::new(Vec2::new(32.0, 0.0)), PolyVertex::new(Vec2::new(32.0, 3.0))],
closed: false,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
let mut lin = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
lin.assoc = vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Start },
];
let mut ali = dim(DimKind::Aligned, Vec3::new(22.0, 0.0, 0.0), Vec3::new(32.0, 3.0, 0.0));
ali.assoc = vec![
DimAssoc { point: "p13".into(), handle: ci, snap: AssocSnap::OnCircle { angle: 0.0 } },
DimAssoc { point: "p14".into(), handle: pl, snap: AssocSnap::Vertex { index: 2 } },
DimAssoc { point: "defpt".into(), handle: l1, snap: AssocSnap::Intersection { other: l2 } },
];
let mut rad = dim(DimKind::Radius, Vec3::new(20.0, 0.0, 0.0), Vec3::ZERO);
rad.assoc = vec![DimAssoc { point: "defpt".into(), handle: ci, snap: AssocSnap::Center }];
let hl = d.add(&Space::Model, c(), EntityKind::Dimension(lin.clone())).unwrap();
let ha = d.add(&Space::Model, c(), EntityKind::Dimension(ali.clone())).unwrap();
let hr = d.add(&Space::Model, c(), EntityKind::Dimension(rad.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("DIMASSOC") && text.contains("ACAD_DIMASSOC") && text.contains("AcDbOsnapPointRef"));
let back = read_dxf(text.as_bytes()).unwrap();
let assoc_of = |d: &Drawing, h: Handle| match &d.entity(h).unwrap().kind {
EntityKind::Dimension(x) => x.assoc.clone(),
_ => panic!(),
};
assert_eq!(assoc_of(&back, hl), lin.assoc);
assert_eq!(assoc_of(&back, ha), ali.assoc);
assert_eq!(assoc_of(&back, hr), rad.assoc);
// Entities owning reactors still land in model space.
assert_eq!(back.model.len(), d.model.len());
assert!(back.layouts.iter().all(|l| l.entities.is_empty()));
// 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 mut stripped = Vec::new();
let mut skipping = false;
for t in tags {
if t.code == 1001 {
skipping = t.str() == "CADCRAFT";
} else if t.code < 1000 {
skipping = false;
}
if !skipping {
stripped.push(t);
}
}
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
assert_eq!(assoc_of(&foreign, hl), lin.assoc);
let fa = assoc_of(&foreign, ha);
assert_eq!(fa.len(), 2);
assert_eq!(fa[1], ali.assoc[1], "the polyline vertex, named by its segment (#1049)");
assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9));
let fr = assoc_of(&foreign, hr);
assert_eq!(fr.len(), 2, "a radial dimension's circle comes from the standard DIMASSOC");
assert_eq!(fr[0], rad.assoc[0], "the centre");
assert!(matches!(fr[1].snap, AssocSnap::OnCircle { angle } if angle.is_finite()));
}
/// #1049: a polyline vertex is referenced as AutoCAD does: an end of one of its segments,
/// numbered from 1 (subentity type 73 = 2, segment in 91, osnap 1 end / 13 start, parameter 1 /
/// 0), preferring a straight segment. The cases are the ones observed in AutoCAD 2026 (black box)
/// in the PR review.
#[test]
fn polyline_vertex_references_name_the_segment() {
let poly = |pts: &[(f64, f64)], closed: bool, bulges: &[(usize, f64)]| LwPolyline {
vertices: pts
.iter()
.enumerate()
.map(|(i, (x, y))| {
let b = bulges.iter().find(|(j, _)| *j == i).map_or(0.0, |(_, b)| *b);
PolyVertex::with_bulge(Vec2::new(*x, *y), b)
})
.collect(),
closed,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
};
let refs = |p: &LwPolyline| (0..p.vertices.len()).map(|i| crate::dxf_write::polyline_vertex_ref(p, i)).collect::<Vec<_>>();
// Open: vertex 0 is the start of segment 1, vertex k the end of segment k.
let open = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (40.0, 60.0)], false, &[]);
assert_eq!(refs(&open), [Some((13, 0.0, 1)), Some((1, 1.0, 1)), Some((1, 1.0, 2)), Some((1, 1.0, 3))]);
// Closed (clockwise): the same; the closing segment is the 4th.
let closed = poly(&[(0.0, 0.0), (0.0, 60.0), (100.0, 60.0), (100.0, 0.0)], true, &[]);
assert_eq!(refs(&closed), [Some((13, 0.0, 1)), Some((1, 1.0, 1)), Some((1, 1.0, 2)), Some((1, 1.0, 3))]);
// An arc as segment 3: its end, vertex 3, is the start of the straight segment 4 instead.
let arc = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (40.0, 60.0), (0.0, 30.0)], true, &[(2, 0.5)]);
assert_eq!(refs(&arc)[3], Some((13, 0.0, 4)));
assert_eq!(refs(&arc)[2], Some((1, 1.0, 2)));
// Between two arcs there is no straight segment: no reference.
let arcs = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0)], false, &[(0, 0.5), (1, 0.5)]);
assert_eq!(refs(&arcs)[1], None);
}
/// #1049: dimensions on the vertices of a polyline are written with segment references, and a
/// file with only those (another writer's, whose reference points AutoCAD leaves at 0,0) is read
/// back to the same vertices.
#[test]
fn polyline_vertex_dimensions_round_trip_by_segment() {
let mut d = Drawing::new_metric();
let c = Common::default;
let pts = [(0.0, 0.0), (160.0, 0.0), (160.0, 210.0), (0.0, 210.0)];
let pl = d
.add(
&Space::Model,
c(),
EntityKind::LwPolyline(LwPolyline {
vertices: pts.iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(),
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
let mut on_pl = dim(DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 }, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 210.0, 0.0));
on_pl.assoc = vec![
DimAssoc { point: "p13".into(), handle: pl, snap: AssocSnap::Vertex { index: 0 } },
DimAssoc { point: "p14".into(), handle: pl, snap: AssocSnap::Vertex { index: 3 } },
];
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();
// Each reference to the polyline: (72, 73, 91).
let refs: Vec<(i64, i64, i64)> = tags
.iter()
.enumerate()
.filter(|(_, t)| t.code == 331 && t.str() == pl.hex())
.map(|(i, _)| {
let back = |code: i32| tags[..i].iter().rev().find(|t| t.code == code).and_then(|t| t.str().parse().ok()).unwrap_or(-1);
let fwd = |code: i32| tags[i..].iter().find(|t| t.code == code).and_then(|t| t.str().parse().ok()).unwrap_or(-1);
(back(72), fwd(73), fwd(91))
})
.collect();
assert_eq!(refs, [(13, 2, 1), (1, 2, 3)]);
// Without CADCraft's xdata and with the reference points at 0,0 (as AutoCAD writes them),
// the segments still give the vertices.
let mut stripped = Vec::new();
let mut skipping = false;
let mut in_ref = false;
for mut t in tags {
if t.code == 1001 {
skipping = t.str() == "CADCRAFT";
} else if t.code < 1000 {
skipping = false;
}
if t.code == 1 {
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));
}
if !skipping {
stripped.push(t);
}
}
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
match &foreign.entity(hp).unwrap().kind {
EntityKind::Dimension(x) => assert_eq!(x.assoc, on_pl.assoc),
_ => panic!(),
}
}
fn parametric_sample(d: &mut Drawing) -> (Vec<Constraint>, Parametric) {
let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(4.0, 0.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(4.0, 0.0, 0.0), b: Vec3::new(4.0, 3.0, 0.0) })).unwrap();
let constraints = vec![
Constraint { id: 1, kind: ConstraintKind::Horizontal, refs: vec![GeomRef::whole(l1)], name: String::new(), expr: String::new() },
Constraint {
id: 2,
kind: ConstraintKind::Coincident,
refs: vec![GeomRef::new(l1, Sub::End), GeomRef::new(l2, Sub::Start)],
name: String::new(),
expr: String::new(),
},
Constraint {
id: 3,
kind: ConstraintKind::Distance(DistAxis::Vertical),
refs: vec![GeomRef::new(l2, Sub::Start), GeomRef::new(l2, Sub::End)],
name: "d1".into(),
expr: "width/2 + 1".into(),
},
Constraint {
id: 4,
kind: ConstraintKind::Angular,
refs: vec![GeomRef::whole(l1), GeomRef::whole(l2)],
name: "ang1".into(),
expr: "90".into(),
},
Constraint {
id: 5,
kind: ConstraintKind::Fix,
refs: vec![GeomRef::new(l2, Sub::Vertex(7)), GeomRef::new(l2, Sub::Segment(3))],
name: String::new(),
expr: String::new(),
},
];
// Long text (chunk boundaries inside strings, spaces at the edges), backslashes that
// would look like DXF `\U+` escapes, and non-ASCII text.
let long = format!(" {} \\U+0041 \\\\ \"quoted\" Ünïcødé ✓ {} ", "word ".repeat(80), "x".repeat(300));
let parametric = Parametric {
parameters: vec![
Parameter { name: "width".into(), expr: "12.5".into(), description: long },
Parameter { name: "h".into(), expr: "width*0.4".into(), description: String::new() },
],
settings: ParametricSettings {
infer: true,
distance_tolerance: 0.125,
angle_tolerance: 2.5,
auto_types: vec!["Parallel".into(), "Tangent".into()],
bars_visible: false,
bar_exceptions: vec![l1],
dims_visible: false,
dim_exceptions: vec![l2],
bar_transparency: 30,
},
};
d.constraints = constraints.clone();
d.parametric = parametric.clone();
(constraints, parametric)
}
#[test]
fn constraints_and_parameters_roundtrip_exactly() {
let mut d = Drawing::new_imperial();
let (constraints, parametric) = parametric_sample(&mut d);
let text = write_dxf(&d);
assert!(text.contains("CADCRAFT_CONSTRAINTS") && text.contains("XRECORD"));
assert!(!text.contains("\\U+0041"), "no DXF unicode escape may appear in the payload");
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.constraints, constraints);
assert_eq!(back.parametric, parametric);
let again = roundtrip(&back);
assert_eq!(again.constraints, constraints);
assert_eq!(again.parametric, parametric);
// Drawings without parametric data carry no record.
assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_CONSTRAINTS"));
}
#[test]
fn layer_states_roundtrip_next_to_constraints() {
let mut d = Drawing::new_imperial();
let (constraints, _) = parametric_sample(&mut d);
d.layers.push(Layer {
name: "Wände".into(),
color: Color::True(cadcraft_color::Rgb(200, 10, 40)),
transparency: 30,
description: "walls \\ hidden".into(),
..Layer::default()
});
d.layer_states.push(LayerState { name: "Plot".into(), layers: d.layers.clone() });
let mut off = d.layers.clone();
for l in &mut off {
(l.on, l.frozen, l.locked, l.plot) = (false, true, true, false);
}
d.layer_states.push(LayerState { name: "All off".into(), layers: off });
let text = write_dxf(&d);
assert!(text.contains("CADCRAFT_LAYERSTATES"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.layer_states, d.layer_states);
assert_eq!(back.constraints, constraints);
assert_eq!(roundtrip(&back).layer_states, d.layer_states);
#[cfg(not(target_arch = "wasm32"))]
assert_eq!(read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap().layer_states, d.layer_states);
// Drawings without saved layer states carry no record.
assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_LAYERSTATES"));
}
fn table_sample() -> Table {
let cell = |t: &str| TableCell { text: t.into(), ..Default::default() };
let mut rows = vec![
vec![cell("Door schedule"), cell(""), cell("")],
vec![cell("Mark"), cell("Size"), cell("Notes")],
vec![cell("D1"), cell("900 x 2100"), cell(&"long note ".repeat(40))],
vec![cell("D2"), cell("800 x 2100"), cell("")],
];
rows[0][0].merged = Some((1, 3));
rows[2][0].merged = Some((2, 1));
Table {
insert: Vec3::new(5.0, 20.0, 0.0),
col_widths: vec![1.5, 2.5, 4.0],
row_heights: vec![0.5, 0.4, 0.4, 0.4],
cells: rows,
style: "Schedule".into(),
text_height: 0.2,
title: true,
header: true,
..Table::default()
}
}
#[test]
fn tables_roundtrip_with_title_header_and_merges() {
let mut d = Drawing::new_imperial();
d.table_styles.push(TableStyle { name: "Schedule".into(), text_height: 0.25, margin: 0.1, title: false, header: true, ..TableStyle::default() });
let t = table_sample();
d.add(&Space::Model, Common::default(), EntityKind::Table(t.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("ACAD_TABLE") && text.contains("TABLESTYLE") && text.contains("*T1"));
let back = read_dxf(text.as_bytes()).unwrap();
let got = first(&back, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None });
assert_eq!(got, t);
assert_eq!(back.table_styles, d.table_styles);
assert!(back.block("*T1").is_none(), "table blocks are regenerated, not imported");
let again = roundtrip(&back);
assert_eq!(first(&again, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None }), t);
assert_eq!(again.blocks.len(), back.blocks.len());
}
#[test]
fn table_block_is_relative_to_the_insertion_point() {
let d = Drawing::new_imperial();
let t = table_sample();
let ents = crate::dxf_write::table_block_entities(&d, &t, "0");
let mut xs = Vec::new();
for e in &ents {
if let EntityKind::Line(l) = &e.kind {
xs.extend([l.a.x, l.b.x]);
}
}
assert!(!xs.is_empty());
let (min, max) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)));
// Columns total 8 wide; the block starts at its own origin, not at the insert x of 5.
assert!(min.abs() < 1e-9 && (max - 8.0).abs() < 1e-9, "block x range {min}..{max}");
}
#[test]
fn text_style_flags_roundtrip() {
let mut d = Drawing::new_imperial();
let st = TextStyle {
name: "Mirror".into(),
font: "romans.shx".into(),
big_font: "bigfont.shx".into(),
height: 0.0,
width_factor: 0.8,
oblique: 15f64.to_radians(),
backwards: true,
upside_down: true,
vertical: true,
annotative: true,
};
d.text_styles.push(st.clone());
d.text_styles.push(TextStyle { name: "Plain".into(), font: "arial.ttf".into(), backwards: true, ..TextStyle::default() });
let back = roundtrip(&d);
let got = back.text_style("Mirror").unwrap();
assert_eq!((got.backwards, got.upside_down, got.vertical, got.annotative), (true, true, true, true));
assert_eq!(got.big_font, "bigfont.shx");
assert!((got.oblique - st.oblique).abs() < 1e-12);
let plain = back.text_style("Plain").unwrap();
assert_eq!((plain.backwards, plain.upside_down, plain.vertical, plain.annotative), (true, false, false, false));
}
/// Non-ASCII text in every kind of string the writer emits: CJK, Latin-1 and an astral
/// character (issue #164).
const INTL: &str = "图号 İø Ø 😀";
fn intl_text(value: &str) -> Text {
Text {
insert: Vec3::new(1.0, 1.0, 0.0),
align_pt: None,
height: 2.5,
value: value.into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
}
}
fn intl_sample() -> Drawing {
let mut d = Drawing::new_metric();
let layer = format!("层 {INTL}");
let block = format!("块 {INTL}");
d.layers.push(Layer { name: layer.clone(), color: Color::Index(3), ..Layer::default() });
let on = Common { layer: layer.clone(), ..Common::default() };
d.add(&Space::Model, on.clone(), EntityKind::Text(intl_text(INTL))).unwrap();
d.add(
&Space::Model,
on.clone(),
EntityKind::MText(MText {
insert: Vec3::new(5.0, 5.0, 0.0),
height: 2.5,
width: 30.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: format!("{INTL}\\P第二行 Ünïcødé"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
)
.unwrap();
let def = Attrib {
tag: "图号".into(),
text: intl_text("默认"),
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
let mut b = Block::new(&block);
b.entities.push(Entity::new(Handle(0x50), EntityKind::AttDef(def)));
d.blocks.insert(block.clone(), std::sync::Arc::new(b));
let att = Attrib {
tag: "图号".into(),
text: intl_text(INTL),
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
d.add(
&Space::Model,
on,
EntityKind::Insert(Insert {
block,
insert: Vec3::new(2.0, 2.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![att],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
.unwrap();
d
}
fn assert_intl(back: &Drawing) {
let layer = format!("层 {INTL}");
let block = format!("块 {INTL}");
assert_eq!(back.layer(&layer).map(|l| l.color), Some(Color::Index(3)));
let text = first(back, |k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None });
assert_eq!(text, INTL);
let mtext = first(back, |k| if let EntityKind::MText(t) = k { Some(t.contents.clone()) } else { None });
assert_eq!(mtext, format!("{INTL}\\P第二行 Ünïcødé"));
let ins = first(back, |k| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None });
assert_eq!(ins.block, block);
assert_eq!(ins.attribs.len(), 1);
assert_eq!(ins.attribs[0].tag, "图号");
assert_eq!(ins.attribs[0].text.value, INTL);
let b = back.block(&block).unwrap();
let def = b.entities.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.clone()) } else { None }).unwrap();
assert_eq!(def.tag, "图号");
assert_eq!(def.text.value, "默认");
assert!(back.model.iter().all(|e| e.common.layer == layer));
}
/// The writer declares R2000 (`AC1015`, `ANSI_1252`), where text is in the code page and not
/// UTF-8, so every non-ASCII character must be a `\U+XXXX` escape and the file pure ASCII.
#[test]
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 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"));
// CJK, Latin-1 and a surrogate pair for the astral character.
assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+00D8 \\U+D83D\\U+DE00"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_intl(&back);
// A second save writes the same text.
assert_eq!(write_dxf(&back).matches("\\U+").count(), text.matches("\\U+").count());
}
/// Real R2007+ files are UTF-8 and older ones are in their code page; both still read.
#[test]
fn reads_utf8_and_code_page_text() {
let file = |ver: &str, value: &[u8]| {
let mut b =
format!("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\n{ver}\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n0\n20\n0\n40\n1\n1\n")
.into_bytes();
b.extend_from_slice(value);
b.extend_from_slice(b"\n0\nENDSEC\n0\nEOF\n");
b
};
let value = |bytes: &[u8]| first(&read_dxf(bytes).unwrap(), |k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None });
assert_eq!(value(&file("AC1032", INTL.as_bytes())), INTL);
assert_eq!(value(&file("AC1015", b"Caf\xe9 \xd8 \\U+56FE\\U+53F7")), "Café Ø 图号");
}
/// Saving as DWG goes through this DXF and acadrust, which decodes the escapes.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_roundtrip_keeps_non_ascii_text() {
let back = read(&write(&intl_sample(), "x.dwg").unwrap(), "x.dwg").unwrap();
assert_intl(&back);
}
#[test]
fn hostile_extension_data_never_panics() {
let ent = |body: &str| format!("0\nSECTION\n2\nENTITIES\n{body}0\nENDSEC\n0\nEOF\n");
let obj = |body: &str| format!("0\nSECTION\n2\nOBJECTS\n{body}0\nENDSEC\n0\nEOF\n");
let cases = [
// DSTYLE: unbalanced, odd counts, unknown codes, huge values, bad handles.
ent("0\nDIMENSION\n5\nA0\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n{\n1070\n"),
ent("0\nDIMENSION\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n{\n1070\n40\n1000\nnot a number\n1070\n271\n1070\n32767\n1070\n342\n1005\nZZZZ\n1070\n278\n1070\n-5\n1070\n176\n1070\n-32000\n1070\n9999\n1040\n1e308\n1002\n}\n"),
ent("0\nDIMENSION\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n}\n1002\n{\n1002\n{\n"),
// ASSOC: malformed links, missing handles, huge vertex index, nested braces.
ent("0\nDIMENSION\n70\n1\n1001\nCADCRAFT\n1000\nASSOC\n1002\n{\n1002\n{\n1000\np13\n1070\n6\n1071\n-1\n1002\n}\n1002\n{\n1000\nbogus\n1005\n1\n1070\n0\n1002\n}\n1002\n{\n1000\np14\n1005\nFFFFFFFFFFFFFFFFFFFF\n1070\n4\n1002\n}\n1002\n{\n1000\np14\n1005\n2A\n1070\n6\n1071\n2147483647\n1002\n}\n"),
ent("0\nDIMENSION\n70\n0\n1001\nCADCRAFT\n1000\nASSOC\n1002\n{\n1002\n{\n1002\n{\n1002\n{\n"),
// Tables: enormous or negative counts, cells without bodies, spans past the edge.
ent("0\nACAD_TABLE\n2\n*T1\n100\nAcDbTable\n91\n999999999\n92\n999999999\n171\n1\n0\nACAD_TABLE\n100\nAcDbTable\n91\n-4\n92\n3\n171\n"),
ent("0\nACAD_TABLE\n100\nAcDbTable\n91\n2\n92\n2\n141\nnan\n142\n-1\n171\n1\n173\n1\n175\n99999\n176\n-3\n1\nx\n171\n171\n171\n171\n171\n2\nzzz\n1001\nCADCRAFT\n1000\nTABLE\n1040\n-1\n"),
// Cell text heights (140): junk, huge, unbalanced value block.
ent("0\nACAD_TABLE\n100\nAcDbTable\n91\n1\n92\n3\n171\n1\n140\nnan\n171\n1\n140\n-5\n301\nCELL_VALUE\n140\n1e308\n171\n1\n304\nACVALUE_END\n140\n1e999\n1\nx\n"),
// DIMASSOC pointing at nothing, or at a non-dimension.
format!(
"{}{}",
"0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n10\n0\n20\n0\n11\n1\n21\n0\n0\nDIMENSION\n5\n11\n70\n0\n0\nENDSEC\n",
"0\nSECTION\n2\nOBJECTS\n0\nDIMASSOC\n100\nAcDbDimAssoc\n330\n11\n90\n-1\n1\nAcDbOsnapPointRef\n72\n1\n331\n10\n1\nAcDbOsnapPointRef\n72\n6\n331\n10\n332\n99\n1\n1\n1\n0\nDIMASSOC\n100\nAcDbDimAssoc\n330\n10\n90\n3\n0\nDIMASSOC\n0\nENDSEC\n0\nEOF\n"
),
// Constraint record: garbage, wrong version, deep nesting, unterminated.
obj("0\nDICTIONARY\n5\nC\n3\nCADCRAFT_CONSTRAINTS\n350\nD\n0\nXRECORD\n5\nD\n1\n{\"version\":1,\"constraints\":[{\"id\":1,\"kind\":\"nope\"}]}\n"),
obj(&format!("0\nDICTIONARY\n3\nCADCRAFT_CONSTRAINTS\n350\nD\n0\nXRECORD\n5\nD\n1\n{}\n", "[".repeat(5000))),
obj("0\nDICTIONARY\n3\nCADCRAFT_CONSTRAINTS\n350\nD\n3\nX\n0\nXRECORD\n5\nD\n1\n{\"version\":99}\n"),
obj("0\nDICTIONARY\n3\nCADCRAFT_CONSTRAINTS\n0\nXRECORD\n1\n{\"version\":1,\n"),
// Table styles with junk numbers; DIMSTYLE with junk and dangling handles.
obj("0\nDICTIONARY\n3\nS\n350\nE\n0\nTABLESTYLE\n5\nE\n40\n-7\n140\n0\n280\n9\n"),
"0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nDIMSTYLE\n0\nDIMSTYLE\n2\nX\n271\n99999\n340\nBAD\n342\n0\n343\nFFFF\n176\n70000\n278\n0\n278\n1114112\n77\n-1\n1001\nAcadAnnotative\n1000\nAnnotativeData\n1002\n{\n0\nSTYLE\n2\nS\n70\n-1\n71\n99999\n0\nENDTAB\n0\nENDSEC\n0\nEOF\n".to_string(),
];
for t in &cases {
if let Ok(d) = read(t.as_bytes(), "x.dxf") {
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let _ = write_dxf(&d);
}
}
// Huge but well-formed override lists are capped, not trusted.
let mut big = String::from("0\nSECTION\n2\nENTITIES\n0\nDIMENSION\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n{\n");
for _ in 0..50_000 {
big.push_str("1070\n41\n1040\n0.5\n");
}
big.push_str("1002\n}\n0\nENDSEC\n0\nEOF\n");
let d = read(big.as_bytes(), "x.dxf").unwrap();
let dm = first(&d, |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
assert_eq!(dm.overrides.len(), 1);
}
/// Everything above in one drawing (also used for external validation).
fn extension_sample() -> Drawing {
let mut d = sample();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), backwards: true, annotative: true, ..TextStyle::default() });
d.dim_styles.push(full_dim_style());
d.table_styles.push(TableStyle { name: "Schedule".into(), text_height: 0.25, margin: 0.1, title: true, header: true, ..TableStyle::default() });
d.add(&Space::Model, Common::default(), EntityKind::Table(table_sample())).unwrap();
let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 30.0, 0.0), b: Vec3::new(0.0, 35.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(8.0, 30.0, 0.0), b: Vec3::new(8.0, 35.0, 0.0) })).unwrap();
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::new(0.0, 30.0, 0.0), Vec3::new(8.0, 30.0, 0.0));
dm.style = "Mech".into();
dm.assoc = vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Start },
];
dm.overrides = serde_json::json!({"arrowBlock2": "_BoxFilled", "textColor": 2, "decimals": 1}).as_object().unwrap().clone();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
let ci = d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 30.0, 0.0), radius: 2.0 })).unwrap();
let mut al = dim(DimKind::Aligned, Vec3::new(22.0, 30.0, 0.0), Vec3::new(8.0, 35.0, 0.0));
al.assoc = vec![
DimAssoc { point: "p13".into(), handle: ci, snap: AssocSnap::OnCircle { angle: 0.0 } },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Intersection { other: l1 } },
];
d.add(&Space::Model, Common::default(), EntityKind::Dimension(al)).unwrap();
parametric_sample(&mut d);
d
}
#[test]
fn extension_sample_roundtrips_and_can_be_exported() {
let d = extension_sample();
let text = write_dxf(&d);
if let Ok(path) = std::env::var("CADCRAFT_DXF_OUT") {
std::fs::write(path, &text).unwrap();
}
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.model.len(), d.model.len());
assert_eq!(back.constraints, d.constraints);
assert_eq!(back.dim_style("Mech"), d.dim_style("Mech"));
let kinds = |d: &Drawing| d.model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
assert_eq!(kinds(&back), kinds(&d));
}
/// DXF → DWG (acadrust) → DXF keeps what the DWG bridge supports: dimension styles with
/// arrow blocks, override and associativity xdata, the constraint XRECORD, tables and text
/// style flags. (TABLESTYLE objects do not survive the bridge.)
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_roundtrip_keeps_extension_data() {
let d = extension_sample();
let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap();
// The bridge loses DIMFXL and DIMJOGANG (0 and its 5° minimum come back).
let norm = |s: Option<&DimStyle>| s.cloned().map(|s| DimStyle { fixed_ext_length: 1.0, jog_angle: 45.0, ..s });
assert_eq!(norm(back.dim_style("Mech")), norm(d.dim_style("Mech")));
assert_eq!(back.constraints, d.constraints);
assert_eq!(back.parametric, d.parametric);
let dims =
|d: &Drawing| d.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect::<Vec<_>>();
let (a, b) = (dims(&d), dims(&back));
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.assoc, y.assoc);
let st = DimStyle::default();
assert_eq!(st.with_overrides(&x.overrides), st.with_overrides(&y.overrides));
}
assert_eq!(first(&back, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None }), table_sample());
let r = back.text_style("Romans").unwrap();
assert!(r.backwards && r.annotative);
}
#[test]
fn mleader_is_written_as_multileader() {
let mut d = Drawing::new_metric();
let text = cadcraft_doc::MText {
insert: Vec3::new(12.0, 5.0, 0.0),
height: 2.5,
width: 0.0,
attach: 1,
rotation: 0.0,
contents: "Note".into(),
style: "Standard".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
let m = cadcraft_doc::MLeader {
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0)]],
landing: Vec3::new(10.0, 5.0, 0.0),
dogleg: 2.0,
text: Some(text),
style: "Standard".into(),
arrow_size: 2.5,
spline: false,
block: None,
};
d.add(&Space::Model, Default::default(), EntityKind::MLeader(m.clone())).unwrap();
// MULTILEADER is a 2007 entity (R2000/2004 files get LEADER + MTEXT).
let back =
crate::read(&crate::write_with(&d, "x.dxf", &crate::SaveOptions { version: Some(crate::Version::R2007), binary: false }).unwrap(), "x.dxf")
.unwrap();
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MLeader(x) if *x == m)));
assert!(!back.model.iter().any(|e| matches!(e.kind, EntityKind::Leader(_) | EntityKind::MText(_))));
}
#[test]
fn attdef_prompt_survives_dxf_roundtrip() {
let mut d = Drawing::new_metric();
let text = Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.0,
value: "X".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
};
let attdef = |prompt: &str| {
EntityKind::AttDef(Attrib {
tag: "TAG1".into(),
text: text.clone(),
invisible: false,
constant: false,
prompt: prompt.into(),
props: Default::default(),
multiline: None,
modes: Default::default(),
})
};
d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap();
d.add(&Space::Model, Default::default(), attdef("")).unwrap();
let back = roundtrip(&d);
let prompts: Vec<&str> =
back.model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.prompt.as_str()) } else { None }).collect();
assert_eq!(prompts, vec!["Enter value", ""]);
}
#[test]
fn dimension_text_rotation_roundtrips() {
let mut d = Drawing::new_imperial();
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
dm.text_rotation = 30.0_f64.to_radians();
let h = d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm.clone())).unwrap();
let back = roundtrip(&d);
let EntityKind::Dimension(x) = &back.entity(h).unwrap().kind else { panic!("dimension expected") };
assert!((x.text_rotation - dm.text_rotation).abs() < 1e-9, "got {}", x.text_rotation);
// The anonymous block that renders the reopened dimension carries the same text angle.
let b = x.block.as_deref().and_then(|n| back.block(n)).expect("dimension block");
let rot = b.entities.iter().find_map(|e| if let EntityKind::MText(t) = &e.kind { Some(t.rotation) } else { None });
assert!(rot.is_some_and(|r| (r - dm.text_rotation).abs() < 1e-9), "block text rotation {rot:?}");
}
/// Wipeout clip vertices are in image pixel space (origin top-left, y down): an asymmetric
/// boundary must keep its orientation, both through our reader and in the file itself.
#[test]
fn wipeout_boundary_keeps_its_orientation() {
let mut d = Drawing::new_imperial();
// A triangle with its apex at the top.
let boundary = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 4.0)];
d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap();
let text = write_dxf(&d);
// The apex (0, 4) is at the top-left corner of the image: pixel (-0.5, -0.5).
let pts: Vec<(f64, f64)> = {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let mut v = Vec::new();
for i in 0..lines.len().saturating_sub(3) {
if lines[i] == "14" && lines[i + 2] == "24" {
v.push((lines[i + 1].parse().unwrap(), lines[i + 3].parse().unwrap()));
}
}
v
};
assert!(pts.iter().any(|&(x, y)| (x + 0.5).abs() < 1e-9 && (y + 0.5).abs() < 1e-9), "{pts:?}");
let back = roundtrip(&d);
let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None });
assert_eq!(&got[..3], &boundary[..]);
}
#[test]
fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() {
let mut d = Drawing::new_metric();
// Arc of radius 10 centred off the origin, so a missing centre would read back as zero.
let mut al = dim(DimKind::ArcLength, Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 15.0, 0.0));
al.p15 = Vec3::new(5.0, 5.0, 0.0);
let h = d.add(&Space::Model, Default::default(), EntityKind::Dimension(al)).unwrap();
let text = write_dxf(&d);
// A standard aligned DIMENSION record (type 1 | 32), so other programs can read it.
let lines: Vec<&str> = text.lines().map(str::trim).collect();
assert!(lines.windows(2).any(|w| w == ["70", "33"]) && !lines.windows(2).any(|w| w == ["70", "40"]), "{text}");
let back = read_dxf(text.as_bytes()).unwrap();
match &back.entity(h).unwrap().kind {
EntityKind::Dimension(x) => {
assert!(matches!(x.kind, DimKind::ArcLength));
assert_eq!(x.p15, Vec3::new(5.0, 5.0, 0.0));
}
_ => panic!("not a dimension"),
}
// And through DWG.
let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
let back = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect();
assert_eq!(dims.len(), 1);
assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind);
assert!((dims[0].p15 - Vec3::new(5.0, 5.0, 0.0)).len() < 1e-9);
}
/// Two gradient hatches (true colours, shifted, rotated; index colours, centered) and a plain
/// solid hatch over the same square.
fn gradient_sample() -> (Drawing, Vec<Option<Gradient>>) {
let mut d = Drawing::new_metric();
let square = vec![
PolyVertex::new(Vec2::ZERO),
PolyVertex::new(Vec2::new(10.0, 0.0)),
PolyVertex::new(Vec2::new(10.0, 10.0)),
PolyVertex::new(Vec2::new(0.0, 10.0)),
];
let gradients = vec![
Some(Gradient {
name: "LINEAR".into(),
color1: Color::True(cadcraft_color::Rgb(255, 0, 0)),
color2: Color::True(cadcraft_color::Rgb(0, 0, 255)),
angle: 30f64.to_radians(),
centered: false,
}),
Some(Gradient { name: "CURVED".into(), color1: Color::Index(5), color2: Color::ByLayer, angle: 0.0, centered: true }),
None,
];
for g in &gradients {
let h = Hatch {
pattern: "SOLID".into(),
solid: true,
loops: vec![HatchLoop { vertices: square.clone(), outer: true, sources: Vec::new() }],
scale: 1.0,
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: g.clone(),
origin: Vec2::ZERO,
background: None,
pattern_lines: Vec::new(),
};
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
}
(d, gradients)
}
fn hatches(d: &Drawing) -> Vec<Hatch> {
d.model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).collect()
}
/// Issue #80: a DXF save/reopen keeps each hatch's gradient (name, both colours, angle and
/// centered setting) as native HATCH groups 450–470, and a plain solid stays plain.
#[test]
fn gradient_hatch_roundtrips_through_dxf() {
let (d, gradients) = gradient_sample();
let text = write_dxf(&d);
for code in ["450", "451", "452", "453", "460", "461", "462", "463", "421", "470"] {
assert!(text.lines().any(|l| l.trim() == code), "missing group {code}");
}
let back = read_dxf(text.as_bytes()).unwrap();
let (a, b) = (hatches(&d), hatches(&back));
assert_eq!(b.len(), gradients.len());
for ((x, y), g) in a.iter().zip(&b).zip(&gradients) {
assert_eq!(&y.gradient, g);
assert_eq!(x.loops, y.loops);
}
assert_eq!(roundtrip(&back).model.iter().map(|e| &e.kind).collect::<Vec<_>>(), back.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
}
/// R2000 readers and DWG conversions drop the native groups; CADCraft's xdata copy restores the
/// gradient. When native groups are present (another program may have edited them) they win.
#[test]
fn gradient_falls_back_to_xdata_and_native_groups_win() {
let (d, gradients) = gradient_sample();
let text = write_dxf(&d);
// Strip groups 450–470 and 63/421 (pairs of lines) from every entity.
let lines: Vec<&str> = text.lines().collect();
let mut stripped = String::new();
for pair in lines.chunks(2) {
let code = pair.first().map(|c| c.trim().parse::<i32>().unwrap_or(-1)).unwrap_or(-1);
if (450..=470).contains(&code) || code == 63 || code == 421 {
continue;
}
for l in pair {
stripped.push_str(l);
stripped.push('\n');
}
}
assert!(!stripped.lines().any(|l| l.trim() == "450"));
let back = read_dxf(stripped.as_bytes()).unwrap();
assert_eq!(hatches(&back).into_iter().map(|h| h.gradient).collect::<Vec<_>>(), gradients);
// Native groups edited elsewhere (new angle, colour, name) take precedence over stale xdata.
let edited = text.replacen("470\r\nLINEAR", "470\r\nSPHERICAL", 1).replacen("421\r\n16711680", "421\r\n65280", 1);
assert_ne!(edited, text);
let back = read_dxf(edited.as_bytes()).unwrap();
let g = hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap();
assert_eq!(g.name, "SPHERICAL");
assert_eq!(g.color1, Color::True(cadcraft_color::Rgb(0, 255, 0)));
}
/// Issue #80: DWG saves (R2000, which has no native gradient fields) keep the gradient too.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gradient_hatch_roundtrips_through_dwg() {
let (d, gradients) = gradient_sample();
let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap();
let hs = hatches(&back);
assert_eq!(hs.iter().map(|h| h.gradient.clone()).collect::<Vec<_>>(), gradients);
assert_eq!(hs.iter().map(|h| h.loops.clone()).collect::<Vec<_>>(), hatches(&d).into_iter().map(|h| h.loops).collect::<Vec<_>>());
}
#[test]
fn hostile_gradient_groups_never_panic() {
let hatch = |tail: &str| {
format!(
"0\nSECTION\n2\nENTITIES\n0\nHATCH\n2\nSOLID\n70\n1\n91\n1\n92\n2\n72\n0\n73\n1\n93\n3\n10\n0\n20\n0\n10\n1\n20\n0\n10\n1\n20\n1\n98\n0\n{tail}0\nENDSEC\n0\nEOF\n"
)
};
let cases = [
// Native: absurd counts, colour records past two, out-of-range ACI/true colours, NaN.
hatch(
"450\n1\n453\n2147483647\n460\nnan\n461\ninf\n463\n0\n63\n99999\n421\n-1\n463\n1\n63\n-32768\n421\n99999999999\n463\n2\n63\n3\n463\n7\n470\n\n",
),
// Native flag only, or a non-gradient flag, or groups out of order.
hatch("450\n1\n"),
hatch("450\n-7\n470\nX\n"),
hatch("421\n255\n63\n1\n463\n0\n450\n1\n470\nLINEAR\n"),
// Xdata: missing values, junk colour names, huge numbers.
hatch("1001\nCADCRAFT\n1000\nGRADIENT\n"),
hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1000\nX\n1040\n1e308\n1070\n-1\n1000\nnot a colour\n1000\n999,999,999\n"),
hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1040\nnan\n1001\nACAD\n1000\n\n"),
];
for t in &cases {
let d = read(t.as_bytes(), "x.dxf").unwrap();
for h in hatches(&d) {
if let Some(g) = &h.gradient {
assert!(g.angle.is_finite());
}
}
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
assert_eq!(
hatches(&back).into_iter().map(|h| h.gradient).collect::<Vec<_>>(),
hatches(&d).into_iter().map(|h| h.gradient).collect::<Vec<_>>()
);
}
}
/// The native 470 group keeps a long name whole; the xdata copy is cut to 255 bytes at a
/// character boundary.
#[test]
fn long_gradient_name_is_cut_only_in_xdata() {
let name = "Ä".repeat(200);
let mut d = Drawing::new_metric();
let mut h = hatches(&gradient_sample().0).into_iter().next().unwrap();
if let Some(g) = &mut h.gradient {
g.name = name.clone();
}
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
let text = write_dxf(&d);
let lines: Vec<&str> = text.lines().collect();
// Non-ASCII text is written as \U+ escapes (R2000 files are ANSI).
assert!(lines.contains(&"\\U+00C4".repeat(200).as_str()));
assert!(lines.contains(&"\\U+00C4".repeat(127).as_str()));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap().name, name);
}
#[test]
fn wipeout_survives_dxf_roundtrip() {
let mut d = Drawing::new_imperial();
let boundary = vec![Vec2::new(2.0, 3.0), Vec2::new(12.0, 3.0), Vec2::new(12.0, 13.0), Vec2::new(2.0, 13.0)];
d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap();
let back = roundtrip(&d);
let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None });
// The DXF polygon is closed, so the first vertex is repeated at the end.
assert_eq!(got.len(), 5);
assert_eq!(&got[..4], &boundary[..]);
assert_eq!(got[4], boundary[0]);
}
#[test]
fn autocad_header_dim_variables_are_not_overrides() {
// AutoCAD writes the current style's values as $DIM* header variables; reading them back must
// not turn them into style overrides for new dimensions (issue #66).
use crate::dxf_ext::{K, dim_code};
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
d.dim_styles.push(full_dim_style());
d.header.set_str("DIMSTYLE", "Mech");
d.header.set_f64("DIMSCALE", full_dim_style().scale);
let st = serde_json::to_value(full_dim_style()).unwrap();
let mut vars = String::new();
for (var, field) in DIMVARS {
let Some((_, kind)) = dim_code(field) else { continue };
let v = &st[*field];
let (code, val) = match kind {
K::Real => (40, v.as_f64().unwrap().to_string()),
K::Int => (70, v.as_i64().unwrap().to_string()),
K::Bool => (70, i64::from(v.as_bool().unwrap()).to_string()),
K::Color => (70, serde_json::from_value::<Color>(v.clone()).unwrap().to_aci().to_string()),
K::Char => (70, u32::from(v.as_str().unwrap().chars().next().unwrap()).to_string()),
K::Str => (1, v.as_str().unwrap().to_string()),
// Names as AutoCAD may spell them.
K::Block => (1, v.as_str().unwrap().to_ascii_uppercase()),
K::TextStyle => (7, v.as_str().unwrap().to_ascii_uppercase()),
K::Linetype => (6, v.as_str().unwrap().to_string()),
K::Angle => (40, v.as_f64().unwrap().to_radians().to_string()),
};
vars.push_str(&format!(" 9\r\n${var}\r\n{code}\r\n{val}\r\n"));
}
let text = write_dxf(&d).replacen("HEADER\r\n", &format!("HEADER\r\n{vars}"), 1);
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.header.str("DIMDSEP", ""), ",");
assert!(back.header.get("DIMTXT").is_some() && back.header.get("DIMCLRD").is_some());
assert_eq!(back.dim_overrides(), serde_json::Map::new());
// A variable that really differs from the style is an override. CADCraft writes the current
// style's variables in the header too (#1051), so both copies change.
let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1).replacen("$DIMDEC\r\n 70\r\n3\r\n", "$DIMDEC\r\n 70\r\n5\r\n", 1);
assert_eq!(read_dxf(text.as_bytes()).unwrap().dim_overrides().get("decimals"), Some(&serde_json::json!(5)));
}
#[test]
fn mirrored_insert_extrusion_maps_to_negative_x_scale() {
// A block reference mirrored in AutoCAD is stored with extrusion (0,0,-1); its insertion point,
// scale and rotation are in that OCS, where x runs the other way: OCS (-10,5) is WCS (10,5).
let text = "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nB\n10\n0\n20\n0\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n0\nENDBLK\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nINSERT\n8\n0\n2\nB\n10\n-10\n20\n5\n50\n90\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "m.dxf").unwrap();
let ins = first(&d, |k| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None });
assert!((ins.insert.x - 10.0).abs() < 1e-9 && (ins.insert.y - 5.0).abs() < 1e-9, "{:?}", ins.insert);
assert_eq!((ins.scale.x, ins.scale.y), (-1.0, 1.0));
assert!((ins.rotation + std::f64::consts::FRAC_PI_2).abs() < 1e-12, "{}", ins.rotation);
// In the OCS the line (0,0)-(2,0) turned by 90° runs from (-10,5) to (-10,7); mirrored into the WCS
// that is (10,5)-(10,7). The same follows from T(10,5)·R(-90°)·S(-1,1): (2,0) → (-2,0) → (0,2) → (10,7).
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, 10.0), (b.min.y, 5.0), (b.max.x, 10.0), (b.max.y, 7.0)] {
assert!((got - want).abs() < 1e-9, "extents {b:?}");
}
// Written back in the WCS (negative x scale, no extrusion), it reads back in the same place.
let b2 = roundtrip(&d).extents(&Space::Model);
assert!((b2.min - b.min).len() < 1e-9 && (b2.max - b.max).len() < 1e-9, "{b2:?} vs {b:?}");
}
#[test]
fn mirrored_ellipse_extrusion_reverses_parameters() {
// Extrusion (0,0,-1) flips the minor axis (extrusion × major): parameters 0..90° of the ellipse
// centred at the origin with major (2,0) and ratio 0.5 run from (2,0) to (0,-1), not (0,1).
let text = "0\nSECTION\n2\nENTITIES\n0\nELLIPSE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n40\n0.5\n41\n0\n42\n1.5707963267948966\n\
210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "e.dxf").unwrap();
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, 0.0), (b.min.y, -1.0), (b.max.x, 2.0), (b.max.y, 0.0)] {
assert!((got - want).abs() < 1e-6, "extents {b:?}");
}
}
#[test]
fn mirrored_2d_polyline_extrusion_flips_x_and_bulge() {
// R12-style 2D POLYLINE vertices are OCS like LWPOLYLINE: with extrusion (0,0,-1), OCS (1,0) is WCS
// (-1,0) and a counter-clockwise bulge turns clockwise.
let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n0\n210\n0\n220\n0\n230\n-1\n\
0\nVERTEX\n8\n0\n10\n1\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n3\n20\n0\n0\nSEQEND\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
let got: Vec<(f64, f64, f64)> = p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect();
assert_eq!(got, vec![(-1.0, 0.0, -1.0), (-3.0, 0.0, 0.0)]);
}
#[test]
fn mirrored_hatch_extrusion_flips_boundary() {
// HATCH boundaries are OCS: the square (1,0)..(3,2) under extrusion (0,0,-1) lies at (-3,0)..(-1,2).
let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n-1\n2\nSOLID\n70\n1\n71\n0\n91\n1\n\
92\n2\n72\n0\n73\n1\n93\n4\n10\n1\n20\n0\n10\n3\n20\n0\n10\n3\n20\n2\n10\n1\n20\n2\n97\n0\n75\n0\n76\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "h.dxf").unwrap();
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, -3.0), (b.min.y, 0.0), (b.max.x, -1.0), (b.max.y, 2.0)] {
assert!((got - want).abs() < 1e-9, "extents {b:?}");
}
}
/// The model extents of a one-loop solid HATCH whose edge boundary is `edges` (group 72 onwards).
fn hatch_edge_extents(edges: &str) -> Bounds2 {
let n = edges.matches("72\n").count();
let text = format!(
"0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n1\n2\nSOLID\n70\n1\n71\n0\n\
91\n1\n92\n1\n93\n{n}\n{edges}97\n0\n75\n0\n76\n1\n98\n0\n0\nENDSEC\n0\nEOF\n"
);
read(text.as_bytes(), "h.dxf").unwrap().extents(&Space::Model)
}
/// Issues #492, #493, #494: a full-circle arc edge, spline edges (knots come before the control
/// points) and clockwise ellipse edges all keep their curve.
#[test]
fn hatch_edges_keep_full_circles_splines_and_clockwise_ellipses() {
let near = |b: Bounds2, want: [f64; 4]| {
let got = [b.min.x, b.min.y, b.max.x, b.max.y];
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "extents {got:?}, want {want:?}");
};
// #492: one arc edge from 0 to 360.
near(hatch_edge_extents("72\n2\n10\n0\n20\n0\n40\n5\n50\n0\n51\n360\n73\n1\n"), [-5.0, -5.0, 5.0, 5.0]);
// #493: a cubic Bezier through (0,0)..(4,0) peaking at y = 3, closed by a line.
let spline = "72\n4\n94\n3\n73\n0\n74\n0\n95\n8\n96\n4\n40\n0\n40\n0\n40\n0\n40\n0\n40\n1\n40\n1\n40\n1\n40\n1\n\
10\n0\n20\n0\n10\n0\n20\n4\n10\n4\n20\n4\n10\n4\n20\n0\n97\n0\n";
near(hatch_edge_extents(&format!("{spline}72\n1\n10\n4\n20\n0\n11\n0\n21\n0\n")), [0.0, 0.0, 4.0, 3.0]);
// #494: the clockwise half-ellipse from 0 to 180 is the lower half, closed by its diameter.
let ellipse = |ccw| format!("72\n3\n10\n0\n20\n0\n11\n5\n21\n0\n40\n0.5\n50\n0\n51\n180\n73\n{ccw}\n72\n1\n10\n-5\n20\n0\n11\n5\n21\n0\n");
near(hatch_edge_extents(&ellipse(0)), [-5.0, -2.5, 5.0, 0.0]);
near(hatch_edge_extents(&ellipse(1)), [-5.0, 0.0, 5.0, 2.5]);
}
/// Issue #923: a HATCH spline edge caps its degree at 10 like the SPLINE reader does, both when the
/// knots are valid for the stated degree and when the reader falls back to the control polygon.
#[test]
fn hatch_spline_edge_caps_degree_like_spline() {
let control: Vec<Vec2> = (0..13).map(|i| Vec2::new(f64::from(i), f64::from(i % 2) * 10.0)).collect();
let points: String = control.iter().map(|c| format!("10\n{}\n20\n{}\n", c.x, c.y)).collect();
let close = "72\n1\n10\n12\n20\n0\n11\n0\n21\n0\n";
// The degree-10 curve over the same controls, which is what the capped edge must produce.
let want = cadcraft_geom::Spline::from_control(control, 10).tessellate(1e-3);
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in &want {
(lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)));
}
// Degree 12 with 13 controls: a valid knot vector has 26 knots; the second edge gives none.
let knots = format!("{}{}", "40\n0\n".repeat(13), "40\n1\n".repeat(13));
for (nk, knots) in [(26, knots.as_str()), (0, "")] {
let edge = format!("72\n4\n94\n12\n73\n0\n74\n0\n95\n{nk}\n96\n13\n{knots}{points}97\n0\n{close}");
let b = hatch_edge_extents(&edge);
for (got, want) in [(b.min.x, lo.x), (b.min.y, lo.y), (b.max.x, hi.x), (b.max.y, hi.y)] {
assert!((got - want).abs() < 1e-6, "extents {b:?}, want {lo:?} {hi:?} (knots {nk})");
}
}
}
#[test]
fn mirrored_text_extrusion_keeps_its_footprint() {
// TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with
// mirrored glyphs). Drawn readable, it becomes right-justified at (10,5) with the same footprint.
let text = "0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n-10\n20\n5\n40\n1\n1\nAB\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "t.dxf").unwrap();
let t = first(&d, |k| if let EntityKind::Text(t) = k { Some(t.clone()) } else { None });
assert_eq!(t.halign, HAlign::Right);
assert!(t.rotation.abs() < 1e-12, "{}", t.rotation);
let a = t.align_pt.expect("right-justified text has an alignment point");
assert!((a.x - 10.0).abs() < 1e-9 && (a.y - 5.0).abs() < 1e-9, "{a:?}");
let list = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
assert!((list.bounds.max.x - 10.0).abs() < 1e-6 && list.bounds.min.x < 9.5, "drawn left of (10,5): {:?}", list.bounds);
}
#[test]
fn mirrored_mtext_extrusion_keeps_its_footprint() {
// MTEXT at (0,0) with direction (1,0), top-left attachment and extrusion (0,0,-1): the line height
// runs along extrusion × direction = (0,-1), so the text box lies above the insertion point
// (glyphs upside down). Drawn readable: turned round, top-right attachment, same box.
let text =
"0\nSECTION\n2\nENTITIES\n0\nMTEXT\n8\n0\n10\n0\n20\n0\n40\n1\n71\n1\n1\nAB\n11\n1\n21\n0\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "mt.dxf").unwrap();
let t = first(&d, |k| if let EntityKind::MText(t) = k { Some(t.clone()) } else { None });
assert_eq!(t.attach, 3);
assert!((t.rotation - std::f64::consts::PI).abs() < 1e-12, "{}", t.rotation);
let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}");
}
// --- SVG import ---
/// One 20mm×10mm page whose user units are millimetres; the reader flips Y, so a point at
/// SVG y lands at `10 - y` in the drawing.
const SVG_PAGE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10">"#;
fn kinds(d: &Drawing) -> Vec<&'static str> {
d.model.iter().map(|e| e.kind.type_name()).collect()
}
fn gzip_of(data: &[u8]) -> Vec<u8> {
let mut crc: u32 = 0xffff_ffff;
for b in data {
crc ^= u32::from(*b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb8_8320u32 & 0u32.wrapping_sub(crc & 1));
}
}
let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(data, 6));
out.extend_from_slice(&(!crc).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out
}
#[test]
fn svg_shapes_become_entities_in_millimetres() {
let svg = format!(
"{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/><circle cx=\"10\" cy=\"5\" r=\"2\"/>\
<ellipse cx=\"10\" cy=\"5\" rx=\"4\" ry=\"2\"/><line x1=\"0\" y1=\"0\" x2=\"20\" y2=\"10\"/>\
<polyline points=\"0,0 5,0 5,5\"/><polygon points=\"0,0 2,0 1,1\"/></svg>"
);
let d = read(svg.as_bytes(), "shapes.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Circle", "Ellipse", "Line", "Polyline", "Polyline"]);
let r = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(r.closed);
let got: Vec<Vec2> = r.vertices.iter().map(|v| v.p).collect();
let want = [Vec2::new(1.0, 9.0), Vec2::new(5.0, 9.0), Vec2::new(5.0, 6.0), Vec2::new(1.0, 6.0)];
for (g, w) in got.iter().zip(want.iter()) {
assert!((*g - *w).len() < 1e-9, "{g:?} vs {w:?}");
}
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
assert!((c.radius - 2.0).abs() < 1e-9 && (c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
let e = first(&d, |k| if let EntityKind::Ellipse(e) = k { Some(e.clone()) } else { None });
let major = Vec2::new(e.major.x - e.center.x, e.major.y - e.center.y);
assert!((major.len() - 4.0).abs() < 1e-9 && (e.ratio - 0.5).abs() < 1e-9, "{e:?}");
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.a.y - 10.0).abs() < 1e-9 && (l.b.y).abs() < 1e-9, "{l:?}");
}
#[test]
fn svg_path_lines_curves_and_arcs() {
let svg = format!("{SVG_PAGE}<path d=\"M1 1 L5 1 C6 1 7 2 8 3\"/><path d=\"M0 0 A5 5 0 0 1 10 0 Z\"/></svg>");
let d = read(svg.as_bytes(), "paths.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Spline", "Polyline"]);
let line_run = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(!line_run.closed && line_run.vertices.len() == 2, "{line_run:?}");
let spline = first(&d, |k| if let EntityKind::Spline(s) = k { Some(s.clone()) } else { None });
assert!(spline.is_valid(), "{spline:?}");
let arc = d
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::LwPolyline(p) if p.vertices.first().is_some_and(|v| v.bulge != 0.0) => Some(p.clone()),
_ => None,
})
.expect("an arc becomes a bulge vertex");
assert!(arc.closed && arc.vertices.len() == 2, "{arc:?}");
let bulge = arc.vertices[0].bulge;
assert!((bulge.abs() - 1.0).abs() < 1e-6, "semicircle bulge is ±1, got {bulge}");
}
#[test]
fn svg_group_transforms_apply() {
let svg = format!("{SVG_PAGE}<g transform=\"translate(5,2)\"><line x1=\"0\" y1=\"0\" x2=\"10\" y2=\"0\"/></g></svg>");
let d = read(svg.as_bytes(), "t.svg").unwrap();
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.a - Vec3::new(5.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
}
/// The geometry of everything read, for comparing two readings of one drawing.
fn geometry(d: &Drawing) -> String {
format!("{:?}", d.model.iter().map(|e| &e.kind).collect::<Vec<_>>())
}
/// `transform` on a shape element (SVG 1.1 §7.5, every graphics element takes one) maps it as
/// the same transform on a group around it would: for every shape kind, composed after the
/// parent group's, and not passed on to the next sibling.
#[test]
fn svg_element_transforms_apply_like_a_group_around_the_element() {
let read_svg = |body: &str| read(format!("{SVG_PAGE}{body}</svg>").as_bytes(), "t.svg").unwrap();
let shapes = [
r#"<rect x="1" y="1" width="4" height="3"/>"#,
r#"<circle cx="5" cy="5" r="2"/>"#,
r#"<ellipse cx="5" cy="5" rx="3" ry="1"/>"#,
r#"<line x1="0" y1="0" x2="4" y2="2"/>"#,
r#"<polyline points="0,0 2,0 2,2"/>"#,
r#"<polygon points="0,0 2,0 1,1"/>"#,
r#"<path d="M1 1 L4 1 A2 2 0 0 1 6 3 C7 3 8 4 8 5"/>"#,
];
for t in ["translate(5,2)", "rotate(30 5 5)", "scale(2,1)", "skewX(20)", "matrix(1 0.2 0 1 3 -1)"] {
for shape in shapes {
let on_element = shape.replacen("/>", &format!(r#" transform="{t}"/>"#), 1);
let in_group = format!(r#"<g transform="{t}">{shape}</g>"#);
assert_eq!(geometry(&read_svg(&on_element)), geometry(&read_svg(&in_group)), "{t} on {shape}");
}
}
// A rotated circle stays a circle; a skewed one is an ellipse.
assert_eq!(kinds(&read_svg(r#"<circle cx="5" cy="5" r="2" transform="rotate(30)"/>"#)), ["Circle"]);
assert_eq!(kinds(&read_svg(r#"<circle cx="5" cy="5" r="2" transform="skewX(20)"/>"#)), ["Ellipse"]);
// Inside a group: the group's transform, then the element's.
let nested = read_svg(r#"<g transform="scale(2)"><line x1="0" y1="0" x2="1" y2="0" transform="translate(1,0)"/></g>"#);
let both = read_svg(r#"<g transform="scale(2)"><g transform="translate(1,0)"><line x1="0" y1="0" x2="1" y2="0"/></g></g>"#);
assert_eq!(geometry(&nested), geometry(&both));
// Written with an end tag too, and the next sibling keeps its own place.
let d = read_svg(r#"<line x1="0" y1="0" x2="1" y2="0" transform="translate(5,0)"></line><line x1="0" y1="0" x2="1" y2="0"/>"#);
let lines: Vec<Line> = d.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect();
assert_eq!(lines.len(), 2);
assert!((lines[0].a - Vec3::new(5.0, 10.0, 0.0)).len() < 1e-9, "{:?}", lines[0]);
assert!((lines[1].a - Vec3::new(0.0, 10.0, 0.0)).len() < 1e-9, "{:?}", lines[1]);
}
#[test]
fn svg_viewbox_scaling_and_mm_units() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 200 100"><circle cx="100" cy="50" r="50"/></svg>"#;
let d = read(svg.as_bytes(), "vb.svg").unwrap();
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
assert!((c.radius - 5.0).abs() < 1e-9, "{c:?}");
assert!((c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
}
#[test]
fn svg_viewbox_with_px_or_unitless_root_size_uses_reference_pixel() {
for w in ["800", "800px"] {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="600" viewBox="0 0 400 300"><line x1="0" y1="0" x2="400" y2="0"/></svg>"#
);
let d = read(svg.as_bytes(), "vb.svg").unwrap();
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.b.x - 800.0 * 25.4 / 96.0).abs() < 1e-9, "{w}: {l:?}");
}
}
#[test]
fn svg_inkscape_layers_are_kept() {
let svg = format!(
"{SVG_PAGE}<g inkscape:groupmode=\"layer\" inkscape:label=\"Cut\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></g>\
<g inkscape:groupmode=\"layer\" inkscape:label=\"Engrave\"><circle cx=\"5\" cy=\"5\" r=\"1\"/></g></svg>",
SVG_PAGE = SVG_PAGE.replace("<svg ", "<svg xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" ")
);
let d = read(svg.as_bytes(), "layers.svg").unwrap();
let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
assert!(layers.contains(&"Cut") && layers.contains(&"Engrave"), "{layers:?}");
let per_layer = |name: &str| d.model.iter().filter(|e| e.common.layer == name).count();
assert_eq!(per_layer("Cut"), 1);
assert_eq!(per_layer("Engrave"), 1);
}
#[test]
fn svg_hidden_and_defs_subtrees_are_skipped() {
let svg = format!(
"{SVG_PAGE}<defs><rect x=\"0\" y=\"0\" width=\"9\" height=\"9\"/></defs><text x=\"0\" y=\"0\">hi</text>\
<rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" display=\"none\"/><rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" style=\"fill:red;display:none\"/>\
<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>"
);
let d = read(svg.as_bytes(), "skip.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline"]);
}
/// #627: what the reader doesn't read yet (text, raster images, `<use>` copies, style sheets,
/// rounded corners) is named in a read note instead of being left out silently; what isn't drawn
/// (in `<defs>` or hidden) isn't counted.
#[test]
fn svg_left_out_content_is_noted() {
let svg = format!(
"{SVG_PAGE}<defs><style>.a {{ stroke: red }}</style><text>in defs</text><rect id=\"r\" width=\"1\" height=\"1\"/></defs>\
<text x=\"0\" y=\"5\">one</text><text x=\"0\" y=\"8\"><tspan>two</tspan></text><text display=\"none\">hidden</text>\
<image href=\"a.png\" width=\"2\" height=\"2\"/><use href=\"#r\"/><use href=\"#r\" x=\"3\"></use>\
<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\" rx=\"1\"/><rect x=\"6\" y=\"1\" width=\"4\" height=\"3\"/></svg>"
);
let d = read(svg.as_bytes(), "left-out.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Polyline"]);
assert_eq!(
d.read_notes,
["SVG: CADCraft doesn't read these yet, so they were left out: 2 texts, 1 raster image, 2 `<use>` copies, \
1 `<style>` sheet (its class rules aren't applied), 1 rectangle with rounded corners (drawn square)"]
);
let plain = read(format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>").as_bytes(), "a.svg").unwrap();
assert!(plain.read_notes.is_empty(), "{:?}", plain.read_notes);
}
#[test]
fn svgz_gunzips_to_the_same_drawing() {
let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>");
let plain = read(svg.as_bytes(), "a.svg").unwrap();
let packed = read(&gzip_of(svg.as_bytes()), "a.svgz").unwrap();
assert_eq!(kinds(&plain), kinds(&packed));
assert_eq!(plain.model.iter().map(|e| &e.kind).collect::<Vec<_>>(), packed.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
}
#[test]
fn svg_read_dispatches_by_extension() {
let svg = format!("{SVG_PAGE}<circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>");
assert!(read(svg.as_bytes(), "a.svg").is_ok());
assert!(read(&gzip_of(svg.as_bytes()), "a.svgz").is_ok());
}
#[test]
fn svg_hostile_input_errors_without_panicking() {
let deep = format!("{SVG_PAGE}{}<line x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"/>{}</svg>", "<g>".repeat(200), "</g>".repeat(200));
for (bytes, name) in [
(b"not xml at all".to_vec(), "a.svg"),
(Vec::new(), "a.svg"),
(b"<svg><rect x=\"1e309\" width=\"NaN\" height=\"inf\"/></svg>".to_vec(), "a.svg"),
(deep.clone().into_bytes(), "a.svg"),
(b"<svg><path d=\"M0 0 X5 5\"/></svg>".to_vec(), "a.svg"),
(b"<svg><path d=\"M0 0 L\"/></svg>".to_vec(), "a.svg"),
(b"<svg><path d=\"M0 0 A1 1 0 5 1 2 2\"/></svg>".to_vec(), "a.svg"),
(b"<svg><polyline points=\"1 2 3\"/></svg>".to_vec(), "a.svg"),
(b"<svg><g transform=\"bogus(1)\"><rect width=\"1\" height=\"1\"/></g></svg>".to_vec(), "a.svg"),
(vec![0x1f, 0x8b, 8, 0, b'j', b'u', b'n', b'k', 0, 0, 0, 0, 0, 0, 0, 0], "a.svgz"),
(vec![0xff, 0xfe, 0x00, 0x01, 0x02, 0x03], "a.svg"),
] {
let _ = read(&bytes, name);
}
let deep = read(deep.as_bytes(), "a.svg");
assert!(deep.is_err(), "200 nested groups are refused, not walked");
}
#[test]
fn svg_no_geometry_yields_an_empty_drawing() {
let svg = format!("{SVG_PAGE}</svg>");
let d = read(svg.as_bytes(), "empty.svg").unwrap();
assert!(kinds(&d).is_empty());
}
/// An application data group inside a kept XRECORD — the AcDs thumbnail schema's
/// `{ATTRRECORD 341 h 2 name 280 1 291 1 ATTRRECORD}` — is written back as read. It used to be
/// collapsed into a handle list: the handle came out under group 291 (a bool) and the name and
/// flags were dropped, which strict DXF readers (ezdxf) reject outright.
#[test]
fn kept_xrecord_application_group_roundtrips_as_read() {
let dxf = "0\nSECTION\n2\nOBJECTS\n\
0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n3\nTD_SCHEMA\n350\n20\n\
0\nXRECORD\n5\n20\n330\nC\n100\nAcDbXrecord\n280\n1\n1\nAcDb_Thumbnail_Schema\n\
102\n{ATTRRECORD\n341\n21\n2\nAcDbDs::TreatedAsObjectData\n280\n1\n291\n1\n102\nATTRRECORD}\n\
0\nXRECORD\n5\n21\n330\n20\n100\nAcDbXrecord\n280\n1\n1\nTarget\n\
0\nENDSEC\n0\nEOF\n";
let d = read_dxf(dxf.as_bytes()).expect("read");
let out = write_dxf(&d);
let lines: Vec<&str> = out.lines().map(str::trim).collect();
let at = lines.iter().position(|l| *l == "{ATTRRECORD").expect("group kept");
// The group keeps its own shape: a reference under 341, then the name, flag and bool.
assert_eq!(lines[at + 1], "341", "reference group code: {:?}", &lines[at..at + 12]);
assert!(lines[at + 2] != "0" && !lines[at + 2].is_empty());
assert_eq!(&lines[at + 3..at + 5], &["2", "AcDbDs::TreatedAsObjectData"]);
assert_eq!(&lines[at + 5..at + 7], &["280", "1"]);
assert_eq!(&lines[at + 7..at + 9], &["291", "1"]);
assert_eq!(&lines[at + 9..at + 11], &["102", "ATTRRECORD}"]);
// And no handle is written under a bool code anywhere.
assert!(!out.contains("291\n2"), "no handle may be written under a bool code");
}
// ---------- plot transparency ----------
/// A black line under a red solid at `percent` transparency (drawn after it, so on top).
fn transparent_over_line(percent: u8) -> Drawing {
let mut d = Drawing::new_metric();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-10.0, 30.0, 0.0), b: Vec3::new(110.0, 30.0, 0.0) })).unwrap();
let corners = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(100.0, 0.0, 0.0), Vec3::new(0.0, 60.0, 0.0), Vec3::new(100.0, 60.0, 0.0)];
let red = Common { color: Color::Index(1), transparency: Transparency::Percent(percent), ..Common::default() };
d.add(&Space::Model, red, EntityKind::Solid(Solid { corners })).unwrap();
d
}
#[test]
fn pdf_plots_transparency_only_when_asked() {
let d = transparent_over_line(50);
let opts = |extra: serde_json::Value| {
let mut o = serde_json::json!({"paper": "A4", "compress": false});
if let (Some(o), Some(e)) = (o.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
o
};
let on = plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap();
let content = check_pdf(&on);
// One graphics state: fill alpha 0.5 (strokes stay opaque).
assert_eq!(pdf_read::read(&on).pages[0].alphas, [(1.0, 0.5)]);
// The line is opaque, the solid at half alpha.
let ops = pdf_read::ops(&content);
assert!(ops.windows(2).any(|w| w[0].op == "gs" && w[1].op == "rg" && w[1].nums == [1.0, 0.0, 0.0]), "{content}");
assert_eq!(pdf_read::count(&content, "gs"), 1, "{content}");
// Off (the default, and explicitly): no graphics states, and the same bytes either way.
let off = plot(&d, &Space::Model, &opts(serde_json::json!({}))).unwrap();
assert_eq!(off, plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": false}))).unwrap());
assert!(pdf_read::read(&off).pages[0].alphas.is_empty() && pdf_read::count(&check_pdf(&off), "gs") == 0);
// On, but nothing transparent: the same bytes as off.
let opaque = transparent_over_line(0);
assert_eq!(
plot(&opaque, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap(),
plot(&opaque, &Space::Model, &opts(serde_json::json!({}))).unwrap()
);
// A hostile flag is ignored (off).
assert_eq!(plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": "yes"}))).unwrap(), off);
}
#[test]
fn layout_page_setup_plot_transparency_is_used() {
let mut d = transparent_over_line(90);
let paper = Space::Paper(d.layouts[0].name.clone());
let corners = [Vec3::new(1.0, 1.0, 0.0), Vec3::new(5.0, 1.0, 0.0), Vec3::new(1.0, 4.0, 0.0), Vec3::new(5.0, 4.0, 0.0)];
let faint = Common { color: Color::Index(5), transparency: Transparency::Percent(90), ..Common::default() };
d.add(&paper, faint, EntityKind::Solid(Solid { corners })).unwrap();
let raw = serde_json::json!({"compress": false});
let alphas = |d: &Drawing, o: &serde_json::Value| pdf_read::read(&plot(d, &paper, o).unwrap()).pages[0].alphas.clone();
assert!(alphas(&d, &raw).is_empty());
d.layouts[0].page.plot_transparency = true;
let on = alphas(&d, &raw);
assert!(on.iter().any(|(_, f)| (f - 0.1).abs() < 1e-6), "the faint solid fills at alpha 0.1: {on:?}");
// The plot's own option wins over the page setup.
assert!(alphas(&d, &serde_json::json!({"compress": false, "plotTransparency": false})).is_empty());
}
#[test]
fn png_and_svg_plots_blend_transparency_when_asked() {
let d = transparent_over_line(50);
let png = |on: Option<bool>| {
let mut o = serde_json::json!({"output": "png", "resolution": 50, "width": 210, "height": 297, "landscape": true});
if let (Some(on), Some(o)) = (on, o.as_object_mut()) {
o.insert("plotTransparency".into(), serde_json::json!(on));
}
plotter::plot(&d, &Space::Model, &o).unwrap()
};
let decode = |bytes: &[u8]| tiny_skia::Pixmap::decode_png(bytes).unwrap();
// The centre of the sheet is inside the solid (the drawing is fitted to the paper).
let centre = |pm: &tiny_skia::Pixmap| {
let c = pm.pixel(pm.width() / 2, pm.height() / 2 + 20).unwrap();
(c.red(), c.green(), c.blue())
};
assert_eq!(centre(&decode(&png(None))), (255, 0, 0), "off: opaque red");
assert_eq!(png(None), png(Some(false)));
let (r, g, b) = centre(&decode(&png(Some(true))));
assert!(r == 255 && g.abs_diff(127) <= 2 && b.abs_diff(127) <= 2, "half red on white paper: {:?}", (r, g, b));
let svg = |on: bool| {
let o = serde_json::json!({"output": "svg", "plotTransparency": on});
String::from_utf8(plotter::plot(&d, &Space::Model, &o).unwrap()).unwrap()
};
assert!(svg(true).contains(r##"fill="#ff0000" opacity="0.50""##), "{}", svg(true));
assert!(!svg(false).contains("opacity"));
}
#[test]
fn view_exports_draw_transparency() {
let d = transparent_over_line(50);
let svg = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap();
assert!(svg.contains(r#"opacity="0.50""#));
assert_ne!(write(&d, "a.png").unwrap(), write(&transparent_over_line(0), "a.png").unwrap());
}
#[test]
fn pdf_strokes_overlapping_transparent_objects_one_by_one() {
// Two crossing lines, ByLayer on layer `layer_t` percent transparent.
let crossing = |layer_t: u8| {
let mut d = Drawing::new_metric();
d.layers.push(Layer { name: "Glass".into(), color: Color::Index(5), transparency: layer_t, ..Layer::default() });
let on = || Common { layer: "Glass".into(), ..Common::default() };
d.add(&Space::Model, on(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(100.0, 100.0, 0.0) })).unwrap();
d.add(&Space::Model, on(), EntityKind::Line(Line { a: Vec3::new(0.0, 100.0, 0.0), b: Vec3::new(100.0, 0.0, 0.0) })).unwrap();
d
};
let content = |d: &Drawing, on: bool| {
check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap())
};
// 50 %: each line is its own stroke, so they build up where they cross.
let glass = content(&crossing(50), true);
assert!(pdf_read::count(&glass, "gs") > 0, "{glass}");
let page =
pdf_read::read(&plot(&crossing(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap()).pages.remove(0);
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
assert_eq!(pdf_read::count(&glass, "S"), 2, "{glass}");
// Opaque: one path as before, whether the option is on or off.
let opaque = content(&crossing(0), true);
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing(0), false));
// Transparency off: the transparent layer plots as before too.
assert_eq!(content(&crossing(50), false), opaque);
}
#[test]
fn pdf_strokes_each_transparent_primitive_inside_a_block() {
// One block reference whose two children cross: they share the reference's handle.
let crossing_block = |t: u8| {
let mut d = Drawing::new_metric();
let mut blk = Block::new("X");
for (i, (a, b)) in [((0.0, 0.0), (100.0, 100.0)), ((0.0, 100.0), (100.0, 0.0))].into_iter().enumerate() {
let line = Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) };
let mut e = Entity::new(Handle(i as u64 + 1), EntityKind::Line(line));
e.common = Common { color: Color::Index(1), transparency: Transparency::ByBlock, ..Common::default() };
blk.entities.push(e);
}
d.blocks.insert("X".into(), std::sync::Arc::new(blk));
let ins = Insert {
block: "X".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
d.add(&Space::Model, Common { transparency: Transparency::Percent(t), ..Common::default() }, EntityKind::Insert(ins)).unwrap();
d
};
let content = |d: &Drawing, on: bool| {
check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap())
};
let half = content(&crossing_block(50), true);
assert!(pdf_read::count(&half, "gs") > 0, "{half}");
let page = pdf_read::read(&plot(&crossing_block(50), &Space::Model, &serde_json::json!({"paper": "A4", "plotTransparency": true})).unwrap())
.pages
.remove(0);
assert!(page.alphas.contains(&(0.5, 1.0)), "strokes at half alpha: {:?}", page.alphas);
assert_eq!(pdf_read::count(&half, "S"), 2, "{half}");
// Opaque: one path, the same with the option on or off.
let opaque = content(&crossing_block(0), true);
assert_eq!(pdf_read::count(&opaque, "S"), 1, "{opaque}");
assert_eq!(opaque, content(&crossing_block(0), false));
assert_eq!(content(&crossing_block(50), false), opaque);
}
#[test]
fn foreign_table_reads_cell_text_height_and_keeps_style_scale() {
// A table from another writer: no CADCraft xdata; cell 1 sets 140 before its value block
// (the cell's text height), cell 2 only has a 140 inside the value block (a number).
let dxf = "0\nSECTION\n2\nENTITIES\n0\nACAD_TABLE\n100\nAcDbTable\n91\n1\n92\n2\n141\n300\n142\n900\n142\n900\n\
171\n1\n140\n150\n301\nCELL_VALUE\n1\nA\n304\nACVALUE_END\n\
171\n1\n301\nCELL_VALUE\n140\n42\n1\nB\n304\nACVALUE_END\n0\nENDSEC\n0\nEOF\n";
let d = read_dxf(dxf.as_bytes()).unwrap();
let t = first(&d, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None });
assert_eq!(t.cells[0][0].text_height, Some(150.0));
assert_eq!(t.cells[0][1].text_height, None);
// No scale of its own: the table takes its style's height.
assert_eq!(t.text_height, TableStyle::default().text_height);
// Written back and read again.
let back = roundtrip(&d);
let t2 = first(&back, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None });
assert_eq!(t2.cells[0][0].text_height, Some(150.0));
assert_eq!(t2.cells[0][1].text_height, None);
}
/// Issue #1209: a file that declares no units ($INSUNITS absent or 0) stays unitless when written
/// again; only a new drawing takes its template's units.
#[test]
fn drawing_without_insunits_stays_unitless() {
let r12 = |extra: &str| {
format!(
"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1009\n{extra}0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"
)
};
let insunits = |text: &str| {
text.lines().map(str::trim).collect::<Vec<_>>().windows(3).find(|w| w[0] == "$INSUNITS" && w[1] == "70").map(|w| w[2].to_string())
};
let rewritten = |extra: &str| insunits(&write_dxf(&read_dxf(r12(extra).as_bytes()).unwrap()));
assert_eq!(rewritten(""), Some("0".into()), "absent");
assert_eq!(rewritten("9\n$INSUNITS\n70\n0\n"), Some("0".into()), "declared unitless");
assert_eq!(rewritten("9\n$INSUNITS\n70\n6\n"), Some("6".into()), "declared metres");
assert_eq!(insunits(&write_dxf(&Drawing::new_metric())), Some("4".into()));
assert_eq!(insunits(&write_dxf(&Drawing::new_imperial())), Some("1".into()));
}
#[test]
fn pdf_clips_a_primitive_beyond_the_coordinate_range() {
let mut d = Drawing::new_metric();
let paper = Space::Paper("Layout1".into());
let v = |x: f64, y: f64| Vec3::new(x, y, 0.0);
// 4e7 mm at 1:1 is about 1.1e8 pt, past the range the writer's f32 geometry takes.
d.add(&paper, Common::default(), EntityKind::Line(Line { a: v(10.0, 20.0), b: v(4.0e7, 20.0) })).unwrap();
d.add(&paper, Common::default(), EntityKind::Solid(Solid { corners: [v(10.0, 50.0), v(4.0e7, 50.0), v(10.0, 60.0), v(10.0, 60.0)] })).unwrap();
let bytes = plot(&d, &paper, &serde_json::json!({"fit": false, "scale": 1.0, "compress": false})).unwrap();
let content = check_pdf(&bytes);
let clip = pdf_read::clip_rect(&content).unwrap();
// The x extent of what each paint operator paints.
let (mut xs, mut painted) = (Vec::new(), Vec::new());
for o in pdf_read::drawn(&content) {
match o.op.as_str() {
"m" | "l" => xs.push(o.nums[0]),
"S" | "f" => painted.push((o.op, xs.drain(..).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(x), b.max(x))))),
_ => {}
}
}
let (stroke, fill) = (painted.iter().find(|p| p.0 == "S"), painted.iter().find(|p| p.0 == "f"));
// Both run from inside the plot area to its right edge: the stroke to just past it.
let (_, (lo, hi)) = stroke.expect("the line is drawn");
assert!(*lo < clip[2] && *hi > clip[2] && *hi < clip[2] + 5.0, "line from {lo} to {hi}, clip {clip:?}");
let (_, (lo, hi)) = fill.expect("the solid is filled");
assert!(*lo < clip[2] && (*hi - clip[2]).abs() < 1e-3, "fill from {lo} to {hi}, clip {clip:?}");
}
#[test]
fn pdf_plots_a_zero_length_line_as_a_dot() {
let mut d = Drawing::new_metric();
let red = |lw| Common { color: Color::Index(1), lineweight: lw, ..Common::default() };
// Points at 1:1 on A4, so the dot sits away from the clip edges.
let at = Vec3::new(50.0, 50.0, 0.0);
d.add(&Space::Model, red(Lineweight::Mm100(100)), EntityKind::Line(Line { a: at, b: at })).unwrap();
// The x extent of what the content fills in red: the dot's diameter.
let red_fill_width = |o: serde_json::Value| {
let content = check_pdf(&plot(&d, &Space::Model, &o).unwrap());
let ops = pdf_read::ops(&content);
let start = ops.iter().position(|o| o.op == "rg" && o.nums == [1.0, 0.0, 0.0]).expect("a red fill");
let end = start + ops[start..].iter().position(|o| o.op == "f").expect("filled");
assert!(ops[start..end].iter().filter(|o| o.op == "c").count() == 4, "{content}");
let xs: Vec<f64> =
ops[start..end].iter().filter(|o| matches!(o.op.as_str(), "m" | "c")).flat_map(|o| o.nums.iter().step_by(2).copied()).collect();
xs.iter().copied().fold(f64::MIN, f64::max) - xs.iter().copied().fold(f64::MAX, f64::min)
};
let opts = |lineweights: bool| serde_json::json!({"paper": "A4", "fit": false, "scale": 1.0, "compress": false, "lineweights": lineweights});
// 1 mm lineweight: a dot 1 mm across.
let w = red_fill_width(opts(true));
assert!((w - 72.0 / 25.4).abs() < 0.01, "{w}");
// A hairline still leaves a mark.
let w = red_fill_width(opts(false));
assert!((w - 0.25).abs() < 0.01, "{w}");
}
/// DXF text as (code, value) pairs.
fn dxf_pairs(text: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = text.lines().collect();
lines.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].trim().to_string(), c[1].trim().to_string())).collect()
}
/// The records of type `kind` in DXF pairs, each as its groups after the type.
fn dxf_records<'a>(pairs: &'a [(String, String)], kind: &str) -> Vec<&'a [(String, String)]> {
let mut out = Vec::new();
for (i, p) in pairs.iter().enumerate() {
if p.0 == "0" && p.1 == kind {
let rest = &pairs[i + 1..];
let end = rest.iter().position(|q| q.0 == "0").unwrap_or(rest.len());
out.push(&rest[..end]);
}
}
out
}
fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> {
rec.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
}
/// #1812: a file whose CLASSES start with VISUALSTYLE (500) and WIDGETX (501), with proxy objects
/// of both classes and of a number past its last class, and a proxy entity of WIDGETX.
fn proxy_file() -> Vec<u8> {
let text = String::from_utf8(write(&Drawing::new_metric(), "p.dxf").unwrap()).unwrap();
let mut pairs = dxf_pairs(&text);
let p = |c: &str, v: &str| (c.to_string(), v.to_string());
let class = |name: &str, cpp: &str| {
vec![p("0", "CLASS"), p("1", name), p("2", cpp), p("3", "ObjectDBX Classes"), p("90", "4095"), p("280", "1"), p("281", "0")]
};
let classes = pairs.iter().position(|q| q.0 == "2" && q.1 == "CLASSES").unwrap() + 1;
let mut injected = class("VISUALSTYLE", "AcDbVisualStyle");
injected.extend(class("WIDGETX", "AcDbWidgetX"));
pairs.splice(classes..classes, injected);
let objects = pairs.iter().position(|q| q.0 == "2" && q.1 == "OBJECTS").unwrap();
let nod = objects + 1;
let nod_handle = pairs[nod + 1].1.clone();
let after = nod + pairs[nod..].iter().position(|q| q.0 == "281").unwrap() + 1;
pairs.splice(after..after, [p("3", "ACAD_VISUALSTYLE"), p("350", "AAA0")]);
let proxy = |h: &str, class: &str, data: &str| {
vec![
p("0", "ACAD_PROXY_OBJECT"),
p("5", h),
// An application's 102 group before the proxy's own groups holds a 91 of its own.
p("102", "{MYAPP"),
p("91", "7"),
p("102", "}"),
p("330", "AAA0"),
p("100", "AcDbProxyObject"),
p("90", "499"),
p("91", class),
p("95", "0"),
p("70", "0"),
p("92", "8"),
p("310", data),
p("94", "0"),
]
};
let mut objs = vec![
p("0", "DICTIONARY"),
p("5", "AAA0"),
p("330", &nod_handle),
p("100", "AcDbDictionary"),
p("281", "1"),
p("3", "Realistic"),
p("350", "AAA1"),
p("3", "Widget"),
p("350", "AAA2"),
p("3", "Stray"),
p("350", "AAA3"),
];
objs.extend(proxy("AAA1", "500", "AA"));
objs.extend(proxy("AAA2", "501", "BB"));
objs.extend(proxy("AAA3", "537", "CC"));
let end = objects + pairs[objects..].iter().position(|q| q.0 == "0" && q.1 == "ENDSEC").unwrap();
pairs.splice(end..end, objs);
let ms = {
let recs = dxf_records(&pairs, "BLOCK_RECORD");
group(recs.iter().find(|r| group(r, "2") == Some("*Model_Space")).unwrap(), "5").unwrap().to_string()
};
let ents = pairs.iter().position(|q| q.0 == "2" && q.1 == "ENTITIES").unwrap() + 1;
pairs.splice(
ents..ents,
[
p("0", "ACAD_PROXY_ENTITY"),
p("5", "AAA4"),
p("330", &ms),
p("100", "AcDbEntity"),
p("8", "0"),
p("100", "AcDbProxyEntity"),
p("90", "498"),
p("91", "501"),
p("92", "0"),
p("93", "0"),
p("94", "0"),
p("95", "0"),
p("70", "0"),
],
);
pairs.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
}
#[test]
fn proxies_keep_their_class_when_classes_are_rewritten() {
let d = read(&proxy_file(), "p.dxf").unwrap();
let text = String::from_utf8(write(&d, "out.dxf").unwrap()).unwrap();
let pairs = dxf_pairs(&text);
let classes: Vec<String> = dxf_records(&pairs, "CLASS").iter().map(|r| group(r, "1").unwrap().to_string()).collect();
let class_of = |n: &str| classes.get(n.parse::<usize>().unwrap() - 500).cloned();
// The proxy's own 91 (after its subclass marker); the application group's stays as it was.
let own_91 = |r: &[(String, String)]| r.iter().skip_while(|q| q.1 != "AcDbProxyObject").find(|q| q.0 == "91").map(|q| q.1.clone()).unwrap();
let objects: Vec<(String, Option<String>)> =
dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| (group(r, "310").unwrap().to_string(), class_of(&own_91(r)))).collect();
assert!(dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().all(|r| group(r, "91") == Some("7")), "the application's 91 changed");
assert_eq!(
objects,
[("AA".to_string(), Some("VISUALSTYLE".to_string())), ("BB".to_string(), Some("WIDGETX".to_string()))],
"classes {classes:?}"
);
let entities: Vec<Option<String>> = dxf_records(&pairs, "ACAD_PROXY_ENTITY").iter().map(|r| class_of(group(r, "91").unwrap())).collect();
assert_eq!(entities, [Some("WIDGETX".to_string())]);
// The proxy whose number named no class is gone, with its dictionary entry.
assert!(!text.contains("\nStray\n"));
// And it stays so through another save.
let again = String::from_utf8(write(&read(text.as_bytes(), "out.dxf").unwrap(), "again.dxf").unwrap()).unwrap();
let pairs = dxf_pairs(&again);
let classes: Vec<String> = dxf_records(&pairs, "CLASS").iter().map(|r| group(r, "1").unwrap().to_string()).collect();
let names: Vec<String> =
dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| classes[own_91(r).parse::<usize>().unwrap() - 500].clone()).collect();
assert_eq!(names, ["VISUALSTYLE", "WIDGETX"]);
}
/// A name that isn't valid Unicode still picks its format by extension.
#[cfg(unix)]
#[test]
fn non_unicode_names_pick_the_format_by_extension() {
use std::os::unix::ffi::OsStringExt as _;
let name = |ext: &str| std::path::PathBuf::from(std::ffi::OsString::from_vec([b"caf\xe9.".as_slice(), ext.as_bytes()].concat()));
assert!(is_image(name("png")) && !is_image(name("dxf")));
let d = sample();
let bytes = write(&d, name("dxf")).unwrap();
assert!(read(&bytes, name("dxf")).is_ok());
assert!(write(&d, name("png")).unwrap().starts_with(b"\x89PNG"));
assert!(write(&d, name("dwt")).is_ok(), "a template is written as DWG");
}
/// A SOLID hatch keeps its angle (52), scale (41) and double flag (77) through a save: the writer
/// wrote them for pattern fills only.
#[test]
fn solid_hatch_keeps_angle_scale_and_double_flag() {
let src = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n5\n40\n8\n0\n100\nAcDbHatch\n10\n0\n20\n0\n30\n0\n2\nSOLID\n70\n1\n71\n0\n\
91\n1\n92\n2\n72\n0\n73\n1\n93\n4\n10\n0\n20\n0\n10\n40\n20\n0\n10\n40\n20\n40\n10\n0\n20\n40\n97\n0\n\
75\n0\n76\n1\n52\n30\n41\n2\n77\n0\n98\n0\n0\nENDSEC\n0\nEOF\n";
let out = write_dxf(&read_dxf(src.as_bytes()).unwrap());
let pairs = dxf_pairs(&out);
let h = dxf_records(&pairs, "HATCH")[0];
let f = |code: &str| group(h, code).and_then(|v| v.parse::<f64>().ok());
assert!(f("52").is_some_and(|a| (a - 30.0).abs() < 1e-9), "{h:?}");
assert!(f("41").is_some_and(|s| (s - 2.0).abs() < 1e-12), "{h:?}");
assert_eq!(group(h, "77"), Some("0"), "{h:?}");
}