ifc_lite_export/
source_header.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Read the ISO 10303-21 `HEADER` section of a source file into the structured
3//! form the writer needs to round-trip header fidelity.
4//!
5//! Twin of `parseSourceHeader` in `packages/parser/src/source-header.ts`; the
6//! two are held together by the shared vectors in
7//! `rust/export/tests/fixtures/step_header_vectors.json`
8//! (`rust/export/tests/step_header_parity.rs` and
9//! `packages/export/src/step-header.parity.test.ts`), not by looking alike.
10//!
11//! Uses the shared quote-aware header reader rather than the generic STEP
12//! value parser: `FILE_DESCRIPTION` items and `FILE_NAME`
13//! fields routinely carry commas and parentheses inside quoted strings (e.g.
14//! `'CoordinateReference [..., ProjectSite: Origin]'`), which a splitter blind
15//! to quote state mis-splits.
16
17use ifc_lite_core::parser::header::{decode_opt_string, decode_opt_string_list,
18    decode_string_list, extract_record_args, find_section_marker, split_top_level,
19    MAX_HEADER_BYTES};
20pub(crate) use ifc_lite_core::parser::header::{declared_schema, declared_schemas};
21#[cfg(test)]
22use ifc_lite_core::parser::header::Lex;
23
24/// The `HEADER` records a source file carried, decoded to Unicode.
25///
26/// Field-for-field the `IfcSourceHeader` of `@ifc-lite/data`. Absent optional
27/// fields are `None`; absent lists are empty, except the two `FILE_NAME` lists,
28/// which are `None` for `$` so a writer can tell "never stated" from a literal
29/// `()` (#5470).
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct SourceHeader {
32    /// `FILE_DESCRIPTION` item list — the MVD/view-definition claim.
33    pub description: Vec<String>,
34    /// `FILE_DESCRIPTION` implementation level; defaults to `2;1` when absent.
35    pub implementation_level: String,
36    /// `FILE_NAME[0]` — the name the source file was written under.
37    pub name: Option<String>,
38    /// `FILE_NAME[1]` — the source's date-time stamp.
39    pub time_stamp: Option<String>,
40    /// `FILE_NAME[2]`; `None` for `$` or no `FILE_NAME` record.
41    pub author: Option<Vec<String>>,
42    /// `FILE_NAME[3]`; `None` for `$` or no `FILE_NAME` record.
43    pub organization: Option<Vec<String>>,
44    /// `FILE_NAME[4]` — the tool that WROTE the source file.
45    pub preprocessor_version: Option<String>,
46    /// `FILE_NAME[5]` — the authoring application behind it.
47    pub originating_system: Option<String>,
48    /// `FILE_NAME[6]`.
49    pub authorization: Option<String>,
50    /// `FILE_SCHEMA` identifiers.
51    pub schema_identifiers: Vec<String>,
52}
53
54/// Parse the `HEADER` section of `content`.
55///
56/// Returns `None` when no recognisable header record is present (a non-STEP
57/// input), which is the caller's signal to fall back to its own defaults rather
58/// than to write empty fields. Cheap: only the first [`MAX_HEADER_BYTES`] are
59/// examined, truncated at the first `ENDSEC` so `DATA` is never scanned.
60pub fn parse_source_header(content: &[u8]) -> Option<SourceHeader> {
61    let cap = content.len().min(MAX_HEADER_BYTES);
62    let raw = String::from_utf8_lossy(&content[..cap]);
63    // The search skips quoted text, for the reason `schema_detect::detect_schema`
64    // gives at its own `ENDSEC;` search: a header field's plain-text VALUE can
65    // carry the literal `ENDSEC`, and a raw byte search cannot tell that from
66    // the terminator — it would cut the header short and drop every record
67    // after it. The offset comes from the string itself, so it is already a
68    // char boundary.
69    let text = match find_section_marker(raw.as_bytes(), b"ENDSEC") {
70        Some(end) => raw[..end].to_string(),
71        None => raw.to_string(),
72    };
73
74    let desc_record = extract_record_args(&text, "FILE_DESCRIPTION");
75    let name_record = extract_record_args(&text, "FILE_NAME");
76    let schema_record = extract_record_args(&text, "FILE_SCHEMA");
77
78    if desc_record.is_none() && name_record.is_none() && schema_record.is_none() {
79        return None;
80    }
81
82    let mut header = SourceHeader {
83        implementation_level: "2;1".to_string(),
84        ..Default::default()
85    };
86
87    if let Some(record) = &desc_record {
88        let parts = split_top_level(record);
89        if let Some(p) = parts.first() {
90            header.description = decode_string_list(p);
91        }
92        if let Some(p) = parts.get(1) {
93            header.implementation_level = decode_opt_string(p).unwrap_or_else(|| "2;1".to_string());
94        }
95    }
96
97    if let Some(record) = &name_record {
98        let parts = split_top_level(record);
99        let at = |n: usize| parts.get(n).map(String::as_str).unwrap_or("");
100        header.name = decode_opt_string(at(0));
101        header.time_stamp = decode_opt_string(at(1));
102        header.author = decode_opt_string_list(at(2));
103        header.organization = decode_opt_string_list(at(3));
104        header.preprocessor_version = decode_opt_string(at(4));
105        header.originating_system = decode_opt_string(at(5));
106        header.authorization = decode_opt_string(at(6));
107    }
108
109    if let Some(record) = &schema_record {
110        header.schema_identifiers = decode_string_list(record);
111    }
112
113    Some(header)
114}
115
116#[cfg(test)]
117#[path = "source_header_tests.rs"]
118mod tests;