DXF: keep layer transparency and description through save and reopen (fixes #96, fixes #97) (#131)

* Render tests: drop the always-true assert in degenerate_input

`assert!(... || true)` trips clippy's overly_complex_bool_expr on current
stable (1.96), which fails `cargo xtask ci` at the clippy step. The check
only proves that NaN input does not panic, so call the function and
discard the result instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DXF: keep layer transparency and description through save and reopen (#96, #97)

The LAYER writer omitted both properties and the reader left them at
their defaults, so a drawing reopened with every layer opaque and every
description empty. Both are xdata on the LAYER record, as AutoCAD writes
them:

- transparency: AcCmTransparency, 1071 = 0x02000000 | alpha, with
  alpha = (100 - percent) * 255 / 100 (40 % is alpha 153); values that
  aren't "by alpha" read as opaque
- description: AcAecLayerStandard, two 1000 strings (the layer
  standard's name, unused, then the description), cut to the 255 bytes
  a 1000 group may hold

Both applications are registered in the APPID table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kaffeochknivar 2026-10-10 18:14:29 +02:00 committed by GitHub
parent b7f83a6687
commit 0cdde077ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 85 additions and 1 deletions

View File

@ -211,6 +211,36 @@ pub(crate) fn arrow_block_name(name: &str) -> Option<(String, Arrowhead)> {
Some((if keep { n.to_string() } else { std_name }, kind))
}
/// Applications whose xdata carries layer properties: transparency and description.
pub(crate) const LAYER_TRANSPARENCY_APP: &str = "AcCmTransparency";
pub(crate) const LAYER_DESCRIPTION_APP: &str = "AcAecLayerStandard";
/// A transparency percentage (0..=90) as an `AcCmTransparency` value: the alpha with the
/// "by alpha" flag (0x02 in the top byte).
pub(crate) fn transparency_to_dxf(percent: u8) -> i64 {
let t = u32::from(percent.min(90));
i64::from(0x0200_0000 | ((100 - t) * 255 / 100))
}
/// The percentage of an `AcCmTransparency` value; `None` unless it is "by alpha".
pub(crate) fn transparency_from_dxf(v: i64) -> Option<u8> {
if (v >> 24) & 0xff != 2 {
return None;
}
let alpha = u32::try_from(v & 0xff).ok()?;
let opaque = (alpha * 100 + 127) / 255;
u8::try_from(100u32.saturating_sub(opaque).min(90)).ok()
}
/// An xdata string cut to the 255 bytes a `1000` group may hold, at a character boundary.
pub(crate) fn xdata_str(s: &str) -> &str {
let mut end = s.len().min(255);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default()
}
/// The extended data of one application: the groups after its `1001` up to the next `1001`.
pub(crate) fn xdata<'a>(tags: &'a [Tag], app: &str) -> &'a [Tag] {
let Some(start) = tags.iter().position(|t| t.code == 1001 && t.str().trim().eq_ignore_ascii_case(app)) else { return &[] };

View File

@ -726,6 +726,13 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
if let Some(tc) = t.i(420) {
l.color = Color::True(Rgb::from_u32(tc as u32));
}
let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071);
if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) {
l.transparency = pct;
}
if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) {
l.description = desc.str();
}
match d.layer_mut(&name) {
Some(x) => *x = l,
None => d.layers.push(l),

View File

@ -1181,6 +1181,16 @@ pub fn write(d: &Drawing) -> String {
w.i(290, 0);
}
w.i(370, i64::from(l.lineweight.to_dxf()));
if l.transparency > 0 {
w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP);
w.i(1071, dxf_ext::transparency_to_dxf(l.transparency));
}
if !l.description.is_empty() {
// The first string is the layer standard's name, unused here.
w.s(1001, dxf_ext::LAYER_DESCRIPTION_APP);
w.s(1000, "");
w.s(1000, dxf_ext::xdata_str(&l.description));
}
}
w.s(0, "ENDTAB");
// STYLE
@ -1207,7 +1217,7 @@ pub fn write(d: &Drawing) -> String {
table_head(&mut w, name, 0);
w.s(0, "ENDTAB");
}
let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative"];
let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative", dxf_ext::LAYER_TRANSPARENCY_APP, dxf_ext::LAYER_DESCRIPTION_APP];
let th = table_head(&mut w, "APPID", apps.len());
for app in apps {
record_head(&mut w, "APPID", &th, "AcDbRegAppTableRecord");

View File

@ -207,6 +207,43 @@ fn second_roundtrip_is_stable() {
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 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";