//! DXF output read back as group codes: versions, entities, colours, units, layers, type, images. use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit}; use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; use crate::*; type Pairs = Vec<(i32, String)>; /// The `(code, value)` pairs of a DXF file. fn pairs(dxf: &[u8]) -> Pairs { let text = std::str::from_utf8(dxf).expect("UTF-8"); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines.len() % 2, 0, "group codes and values come in pairs"); lines.chunks(2).map(|p| (p[0].trim().parse().expect("a group code"), p[1].to_string())).collect() } /// The objects of section `name`: each the pairs from its `0` code on. fn section(p: &[(i32, String)], name: &str) -> Vec { let start = p.windows(2).position(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())); let Some(start) = start else { return vec![] }; let mut out: Vec = vec![]; for pair in &p[start + 2..] { if *pair == (0, "ENDSEC".into()) { break; } if pair.0 == 0 { out.push(vec![]); } if let Some(o) = out.last_mut() { o.push(pair.clone()); } } out } fn kind(o: &[(i32, String)]) -> &str { o.first().map_or("", |p| p.1.as_str()) } fn get(o: &[(i32, String)], code: i32) -> Option<&str> { o.iter().find(|p| p.0 == code).map(|p| p.1.as_str()) } fn all(o: &[(i32, String)], code: i32) -> Vec { o.iter().filter(|p| p.0 == code).map(|p| p.1.trim().parse().expect("a number")).collect() } fn header(p: &[(i32, String)], var: &str) -> Option { let i = p.iter().position(|x| *x == (9, var.into()))?; p.get(i + 1).map(|x| x.1.clone()) } fn entities(out: &DxfOutput) -> Vec { section(&pairs(&out.bytes), "ENTITIES") } fn kinds(out: &DxfOutput) -> Vec { entities(out).iter().map(|e| kind(e).to_string()).collect() } fn table(out: &DxfOutput, kind_: &str) -> Vec { section(&pairs(&out.bytes), "TABLES").into_iter().filter(|o| kind(o) == kind_).collect() } /// A 200 × 100 pt document whose first layer holds what `nodes` makes. fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { let mut d = Document::new(200.0, 100.0); let nodes = nodes(&mut d); let layer = d.layers[0].id; for n in nodes { d.insert(Some(layer), usize::MAX, n).unwrap(); } d } fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node { Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width)) } /// The options with the first artboard as the region, in points. fn opts(d: &Document) -> DxfOptions { DxfOptions { region: d.artboards[0].rect, unit: Unit::Points, ..DxfOptions::default() } } /// A red-filled rectangle with a black 1 pt stroke, and an unfilled blue 2 pt ellipse. fn shapes_doc() -> Document { doc_with(|d| { vec![ path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0), path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0), ] }) } #[test] fn every_version_declares_its_acadver_and_is_well_formed() { let d = shapes_doc(); for v in DxfVersion::ALL { let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap(); let p = pairs(&out.bytes); assert_eq!(header(&p, "$ACADVER").as_deref(), Some(v.acadver()), "{v:?}"); assert_eq!(p.last(), Some(&(0, "EOF".to_string())), "{v:?}"); let opened = p.iter().filter(|x| **x == (0, "SECTION".into())).count(); assert_eq!(opened, p.iter().filter(|x| **x == (0, "ENDSEC".into())).count(), "{v:?}: balanced sections"); assert!(!entities(&out).is_empty(), "{v:?}"); if v >= DxfVersion::R13 { // Every handle is unique and below $HANDSEED. let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..]; let handles: Vec = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect(); let unique: std::collections::HashSet<_> = handles.iter().collect(); assert_eq!(unique.len(), handles.len(), "{v:?}: unique handles"); let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap(); assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed"); assert!(section(&p, "OBJECTS").iter().any(|o| kind(o) == "DICTIONARY"), "{v:?}: root dictionary"); } else { assert!(header(&p, "$HANDSEED").is_none()); } } assert_eq!(DxfVersion::from_id("r2013"), Some(DxfVersion::R2013)); assert_eq!(DxfVersion::from_id("AC1009"), Some(DxfVersion::R12)); assert_eq!(DxfVersion::from_id("2019"), None); } #[test] fn lines_are_lwpolylines_curves_splines_and_fills_hatches() { let d = shapes_doc(); let out = export(&d, &opts(&d)).unwrap(); assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke"); let e = entities(&out); // The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90. let poly = &e[1]; assert_eq!(get(poly, 70), Some("1"), "closed"); let mut xs = all(poly, 10); xs.sort_by(f64::total_cmp); assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]); let mut ys = all(poly, 20); ys.sort_by(f64::total_cmp); assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]); let hatch = &e[0]; assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4"))); // The ellipse: four cubics through clamped knots. let spline = &e[2]; assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13"))); assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]); // R14 has lightweight polylines but no lineweights: the stroke is a polyline width. let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap(); assert_eq!(get(&entities(&r14)[1], 43), Some("1")); assert_eq!(get(&entities(&r14)[1], 370), None); // R13 has splines and hatches, and polylines of vertices. let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap(); assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]); // R12: polylines only; the fill is its outline, the ellipse flattened. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); let k = kinds(&r12); assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}"); assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3); assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened"); assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings); } #[test] fn colours_map_to_the_index_palette_or_true_colour() { let d = shapes_doc(); let style = |o: &DxfOutput, i: usize| { let e = &entities(o)[i]; (get(e, 62).map(str::to_string), get(e, 420).map(str::to_string)) }; let out = export(&d, &opts(&d)).unwrap(); assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000"); assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7"); assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into()))); for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] { let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap(); assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}"); } let orange = doc_with(|d| { vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::from_hex("#ff8000").unwrap()), Paint::None, 0.0)] }); let aci = |depth| get(&entities(&export(&orange, &DxfOptions { colors: depth, ..opts(&orange) }).unwrap())[0], 62).unwrap().to_string(); assert_eq!( (aci(ColorDepth::Aci256), aci(ColorDepth::Aci16), aci(ColorDepth::Aci8)), ("30".into(), "30".into(), "2".into()), "8 colours: yellow is nearest" ); // True colour needs 2004: earlier versions fall back to indices, and say so. let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap(); assert_eq!(style(&old, 0), (Some("1".into()), None)); assert!(old.warnings.iter().any(|w| w.contains("true colour"))); } #[test] fn units_scale_coordinates_and_set_insunits() { let d = shapes_doc(); let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS"); let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max); let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap(); assert_eq!(insunits(&mm).as_deref(), Some("4")); assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5); assert_eq!(header(&pairs(&mm.bytes), "$MEASUREMENT").as_deref(), Some("1")); let inches = export(&d, &DxfOptions { unit: Unit::Inches, ..opts(&d) }).unwrap(); assert_eq!(insunits(&inches).as_deref(), Some("1")); // 1 in = 10 units: unitless, coordinates ten times larger. let scaled = export(&d, &DxfOptions { unit: Unit::Inches, scale: 10.0, ..opts(&d) }).unwrap(); assert_eq!(insunits(&scaled).as_deref(), Some("0")); assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5); // Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked. assert_eq!(get(&entities(&scaled)[1], 370), Some("35")); let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap(); assert_eq!(get(&entities(&heavy)[1], 370), Some("70")); assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err()); assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); let second = d.add_layer(Some("Dims: ")); let hidden = d.add_layer(Some("Notes")); let template = d.add_layer(Some("Tracing")); for l in [second, hidden, template] { let n = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Paint::solid(Color::BLACK), Paint::None, 0.0); d.insert(Some(l), 0, n).unwrap(); } if let Some(n) = d.node_mut(hidden) { n.visible = false; } if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) { *template = true; } let out = export(&d, &opts(&d)).unwrap(); let layers: Vec<(String, String)> = table(&out, "LAYER").iter().map(|o| (get(o, 2).unwrap().to_string(), get(o, 62).unwrap().to_string())).collect(); let names: Vec<&str> = layers.iter().map(|l| l.0.as_str()).collect(); assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out"); assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off"); let on: Vec = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect(); assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]); // Before 2000: capitals, letters, digits and $-_ only. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); let names: Vec = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect(); assert_eq!(names, ["0", "LAYER_1", "DIMS___TOP_", "NOTES"]); } #[test] fn dashes_become_linetypes_and_opacity_transparency() { let d = doc_with(|d| { let mut n = path(d, shapes::line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), Paint::None, Paint::solid(Color::BLACK), 1.0); if let Some(st) = n.appearance.stroke_mut() { st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }); } n.opacity = 0.5; vec![n] }); let out = export(&d, &opts(&d)).unwrap(); let e = &entities(&out)[0]; let alpha = (0x0200_0000 | 128).to_string(); assert_eq!((get(e, 6), get(e, 440)), (Some("DASHED1"), Some(alpha.as_str()))); let lt = table(&out, "LTYPE").into_iter().find(|o| get(o, 2) == Some("DASHED1")).unwrap(); assert_eq!(all(<, 49), [6.0, -3.0]); let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap(); assert!(get(&entities(&old)[0], 440).is_none()); assert!(old.warnings.iter().any(|w| w.contains("transparency"))); } #[test] fn alter_paths_writes_strokes_as_filled_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]); // The rectangle's stroke: an outer and an inner boundary. assert_eq!(get(&entities(&out)[1], 91), Some("2")); // Preserve Appearance outlines only the strokes a line can't draw. let mut d = shapes_doc(); let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) { st.align = vectorcraft_doc::StrokeAlign::Outside; } assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]); let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]); assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment"))); } fn text_doc(text: &str, size: f64) -> Document { doc_with(|d| { let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() }; let t = TextObject::point(Point::new(10.0, 50.0), text, style); vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))] }) } #[test] fn type_is_text_or_outlines() { let d = text_doc("Plan A", 20.0); let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); let e = entities(&editable); assert_eq!(e.len(), 1); assert_eq!((kind(&e[0]), get(&e[0], 1)), ("TEXT", Some("Plan A"))); assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "the cap height of 20 pt type"); assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 50.0), "the baseline start, y up"); let family = CharStyle::default().font_family; let style = table(&editable, "STYLE").into_iter().find(|o| get(o, 3) == Some(&*format!("{family}.ttf"))).unwrap(); assert_eq!(get(&e[0], 7), get(&style, 2)); // Outline Text, and Preserve Appearance: the glyphs as filled outlines. for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] { let k = kinds(&export(&d, &o).unwrap()); assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}"); } } #[test] fn text_outside_ascii_is_escaped_before_2007() { let d = text_doc("Größe", 12.0); let text = |v| { let out = export(&d, &DxfOptions { version: v, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); get(&entities(&out)[0], 1).unwrap().to_string() }; assert_eq!(text(DxfVersion::R2004), "Gr\\U+00F6\\U+00DFe"); assert_eq!(text(DxfVersion::R2018), "Größe"); } #[test] fn images_link_png_or_jpeg_files() { let mut png = vec![]; image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut d = doc_with(|d| { let im = ImageObject { key: "raster-1".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None, placement: Default::default(), }; vec![Node::new(d.alloc_id(), NodeKind::Image(im))] }); d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); let out = export(&d, &opts(&d)).unwrap(); let e = entities(&out); assert_eq!(kind(&e[0]), "IMAGE"); // The bottom-left corner (10, 30) is (10, 70) y up; a pixel is 5 units. assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 70.0)); assert_eq!((all(&e[0], 11)[0], all(&e[0], 22)[0], all(&e[0], 13)[0], all(&e[0], 23)[0]), (5.0, 5.0, 4.0, 2.0)); assert_eq!(out.images.len(), 1); assert!(out.images[0].name.ends_with(".png") && out.images[0].bytes.starts_with(b"\x89PNG")); let p = pairs(&out.bytes); let objects = section(&p, "OBJECTS"); let def = objects.iter().find(|o| kind(o) == "IMAGEDEF").unwrap(); assert_eq!(get(def, 1), Some(out.images[0].name.as_str())); assert_eq!(get(&e[0], 340), get(def, 5), "the image points at its definition"); assert!(objects.iter().any(|o| kind(o) == "IMAGEDEF_REACTOR")); assert!(section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); let jpeg = export(&d, &DxfOptions { raster: RasterFormat::Jpeg, ..opts(&d) }).unwrap(); assert!(jpeg.images[0].name.ends_with(".jpg") && jpeg.images[0].bytes.starts_with(&[0xFF, 0xD8])); let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); assert!(entities(&r12).is_empty() && r12.images.is_empty()); assert!(r12.warnings.iter().any(|w| w.contains("no images"))); } #[test] fn crop_leaves_out_art_outside_the_region() { let d = shapes_doc(); // Only the ellipse (120..180) reaches into 115..200. let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) }; assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]); assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3); }