Skip to main content

ifc_xml/
writer.rs

1//! [`Model`] to ifcXML text.
2//!
3//! # The lossless-encoding problem
4//!
5//! STEP distinguishes `$` (unset), `*` (derived), `.T.` (logical true),
6//! `.ELEMENT.` (enum), `'text'` (string) and `1.` (real) syntactically. XML
7//! attribute values are all just strings, so a naive writer collapses those
8//! distinctions and the round-trip loses type information.
9//!
10//! This writer preserves the distinction structurally: scalars become
11//! attributes, and anything whose *kind* cannot be inferred from an attribute
12//! string is written as a typed child element. That keeps the output readable
13//! for the common case while remaining exactly reversible.
14//!
15//! That is this crate's own layout. A codec in [`XmlLayout::Xsd`] writes the
16//! buildingSMART XSD configuration instead, by the rules in the `xsd`
17//! module, and refuses what that configuration cannot carry exactly.
18
19use crate::error::XmlError;
20use crate::scalar::{attribute_text, element_form, format_ref};
21use crate::{slots, XmlCodec, XmlLayout};
22use ifc_model::{Model, Value};
23use std::fmt::Write as _;
24
25const XSI_NAMESPACE: &str = "http://www.w3.org/2001/XMLSchema-instance";
26
27fn reject_non_finite_reals(model: &Model) -> Result<(), XmlError> {
28    for (id, entity) in model.iter() {
29        for (slot, value) in entity.attributes.iter().enumerate() {
30            if let Some(non_finite) = first_non_finite_real(value) {
31                let path = format!("/ifcXML/{}[@id='i{}']/a{}", entity.type_name, id.0, slot);
32                return Err(XmlError::InvalidScalar {
33                    kind: "real".into(),
34                    value: non_finite.to_string(),
35                }
36                .at(path));
37            }
38        }
39    }
40    Ok(())
41}
42
43fn first_non_finite_real(value: &Value) -> Option<f64> {
44    match value {
45        Value::Real(value) if !value.is_finite() => Some(*value),
46        Value::List(values) => values.iter().find_map(first_non_finite_real),
47        Value::Typed { value, .. } => first_non_finite_real(value),
48        _ => None,
49    }
50}
51
52/// Serialize a model as ifcXML.
53pub fn write(codec: &XmlCodec, model: &Model) -> Result<Vec<u8>, XmlError> {
54    if codec.layout() == XmlLayout::Xsd {
55        return write_xsd(codec, model);
56    }
57    reject_non_finite_reals(model)?;
58    #[cfg(feature = "schema")]
59    let mut strict = codec.strict_schema().map(crate::typing::Layouts::new);
60    let mut out = String::with_capacity(model.len() * 96);
61    let schema_token = model.header().schema.first().cloned().unwrap_or_default();
62    let namespace = if let Some(profile) = codec.profile() {
63        if schema_token != profile.schema_token() {
64            return Err(XmlError::Profile {
65                expected: profile.schema_token(),
66                found: (!schema_token.is_empty()).then_some(schema_token),
67            });
68        }
69        profile.namespace()
70    } else {
71        "http://www.buildingsmart-tech.org/ifcXML/IFC4/final"
72    };
73
74    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
75    write!(
76        out,
77        "<ifcXML xmlns=\"{namespace}\" xmlns:xsi=\"{XSI_NAMESPACE}\""
78    )
79    .map_err(fmt_err)?;
80    write_attr(&mut out, "schema", &schema_token);
81    out.push_str(">\n");
82
83    write_header(&mut out, model);
84
85    for (id, entity) in model.iter() {
86        let names = slots::attribute_names(codec, &entity.type_name, entity.attributes.len());
87
88        write!(out, "  <{}", entity.type_name).map_err(fmt_err)?;
89        write_attr(&mut out, "id", &format_ref(id));
90
91        // Scalars become XML attributes; everything else becomes a child.
92        #[cfg(feature = "schema")]
93        let layout = match strict.as_mut() {
94            // A type the schema cannot resolve is one the strict reader
95            // refuses whatever the writer does; write it losslessly anyway.
96            Some(layouts) => layouts
97                .entity(&entity.type_name, false)
98                .ok()
99                .flatten()
100                .filter(|layout| layout.slots.len() >= entity.attributes.len()),
101            None => None,
102        };
103        let mut children: Vec<(usize, &Value)> = Vec::new();
104        for (i, value) in entity.attributes.iter().enumerate() {
105            let text = attribute_text(value);
106            // A strict reader types the text from the slot's declaration, so
107            // an attribute is used only where that typing agrees as well.
108            #[cfg(feature = "schema")]
109            let text = text.filter(|text| {
110                layout.as_ref().is_none_or(|layout| {
111                    let shape = &layout.slots[i].shape;
112                    shape.levels.is_empty()
113                        && crate::typing::scalar(&shape.leaf, text, crate::typing::Lexical::Native)
114                            .is_ok_and(|typed| crate::scalar::same_scalar(&typed, value))
115                })
116            });
117            match text {
118                Some(text) => write_attr(&mut out, &names[i], &text),
119                None => children.push((i, value)),
120            }
121        }
122
123        if children.is_empty() {
124            out.push_str("/>\n");
125        } else {
126            out.push_str(">\n");
127            for (i, value) in children {
128                write_child(&mut out, &names[i], value, 2)?;
129            }
130            writeln!(out, "  </{}>", entity.type_name).map_err(fmt_err)?;
131        }
132    }
133
134    out.push_str("</ifcXML>\n");
135    Ok(out.into_bytes())
136}
137
138/// The buildingSMART XSD configuration, from the codec's schema and profile.
139#[cfg(feature = "schema")]
140fn write_xsd(codec: &XmlCodec, model: &Model) -> Result<Vec<u8>, XmlError> {
141    match (codec.schema(), codec.profile()) {
142        (Some(schema), Some(profile)) => crate::xsd::write(schema, profile, model),
143        _ => Err(XmlError::Unsupported {
144            construct: "the XSD layout without a schema and release profile".into(),
145        }),
146    }
147}
148
149#[cfg(not(feature = "schema"))]
150fn write_xsd(_: &XmlCodec, _: &Model) -> Result<Vec<u8>, XmlError> {
151    Err(XmlError::Unsupported {
152        construct: "the XSD layout without the `schema` feature".into(),
153    })
154}
155
156fn fmt_err(e: std::fmt::Error) -> XmlError {
157    XmlError::Write(e.to_string())
158}
159
160/// The header, mirroring STEP's `FILE_DESCRIPTION`/`FILE_NAME` content.
161fn write_header(out: &mut String, model: &Model) {
162    let h = model.header();
163    out.push_str("  <header>\n");
164    push_element(out, "name", &h.name);
165    push_element(out, "time_stamp", &h.time_stamp);
166    push_element(out, "preprocessor_version", &h.preprocessor_version);
167    push_element(out, "originating_system", &h.originating_system);
168    push_element(out, "authorization", &h.authorization);
169    for a in &h.author {
170        push_element(out, "author", a);
171    }
172    for o in &h.organization {
173        push_element(out, "organization", o);
174    }
175    for d in &h.description {
176        push_element(out, "description", d);
177    }
178    out.push_str("  </header>\n");
179}
180
181fn push_element(out: &mut String, tag: &str, text: &str) {
182    if text.is_empty() {
183        return;
184    }
185    out.push_str("    <");
186    out.push_str(tag);
187    out.push('>');
188    escape_into(out, text);
189    out.push_str("</");
190    out.push_str(tag);
191    out.push_str(">\n");
192}
193
194/// Write a value that needs its own element to preserve its kind.
195fn write_child(out: &mut String, name: &str, value: &Value, depth: usize) -> Result<(), XmlError> {
196    let pad = "  ".repeat(depth);
197    match value {
198        Value::Null => {
199            writeln!(out, "{pad}<{name} xsi:nil=\"true\"/>").map_err(fmt_err)?;
200        }
201        Value::Derived => {
202            writeln!(out, "{pad}<{name} derived=\"true\"/>").map_err(fmt_err)?;
203        }
204        Value::Typed { type_name, value } => {
205            write!(out, "{pad}<{name} kind=\"typed\"").map_err(fmt_err)?;
206            write_attr(out, "type", type_name);
207            out.push_str(">\n");
208            write_child(out, "value", value, depth + 1)?;
209            writeln!(out, "{pad}</{name}>").map_err(fmt_err)?;
210        }
211        Value::List(items) => {
212            writeln!(out, "{pad}<{name} kind=\"list\">").map_err(fmt_err)?;
213            for item in items {
214                write_child(out, "item", item, depth + 1)?;
215            }
216            writeln!(out, "{pad}</{name}>").map_err(fmt_err)?;
217        }
218        leaf => {
219            // Leaf kinds always carry an explicit `kind` here, including the
220            // text, numbers and references that `attribute_text` refused: an
221            // element without one would be re-read by inference.
222            let (kind, text) = element_form(leaf)
223                .ok_or_else(|| XmlError::Write(format!("no element form for value {leaf:?}")))?;
224            write!(out, "{pad}<{name} kind=\"{kind}\">").map_err(fmt_err)?;
225            escape_into(out, &text);
226            writeln!(out, "</{name}>").map_err(fmt_err)?;
227        }
228    }
229    Ok(())
230}
231
232fn write_attr(out: &mut String, name: &str, value: &str) {
233    out.push(' ');
234    out.push_str(name);
235    out.push_str("=\"");
236    escape_into(out, value);
237    out.push('"');
238}
239
240/// XML-escape into an existing buffer.
241///
242/// Tab, line feed and carriage return are written as character references:
243/// a conforming parser normalizes them to spaces inside attribute values and
244/// folds CR/LF line ends in text, so a literal one would not survive.
245pub(crate) fn escape_into(out: &mut String, text: &str) {
246    for c in text.chars() {
247        match c {
248            '\t' => out.push_str("&#9;"),
249            '\n' => out.push_str("&#10;"),
250            '\r' => out.push_str("&#13;"),
251            '&' => out.push_str("&amp;"),
252            '<' => out.push_str("&lt;"),
253            '>' => out.push_str("&gt;"),
254            '"' => out.push_str("&quot;"),
255            '\'' => out.push_str("&apos;"),
256            c => out.push(c),
257        }
258    }
259}