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;