Skip to main content

ifc_lite_core/
step_encoding.rs

1//! STEP string escape decoding/encoding (ISO 10303-21 / IFC).
2//!
3//! IFC string attribute values encode non-ASCII characters with backslash
4//! escape sequences. This module decodes them to native UTF-8 so the Rust
5//! crates, CLI, and server surface the same text the browser parser does via
6//! `decodeIfcString` in `@ifc-lite/encoding`. The two decoders are pinned to a
7//! shared test-vector fixture (`tests/fixtures/ifc_string_vectors.json`).
8//!
9//! Supported escapes:
10//! - `\X2\HHHH..\X0\` UTF-16 code units, 4 hex digits each (surrogate pairs ok)
11//! - `\X4\HHHHHHHH..\X0\` Unicode scalar values, 8 hex digits each
12//! - `\X\HH` single ISO-8859-1 byte
13//! - `\S\C` extended ASCII: code point of `C` plus 128
14//! - `\PC\` code-page directive, consumed and dropped
15//!
16//! Unknown or malformed escapes are passed through unchanged. The `''`
17//! doubled-quote escape is NOT handled here — the tokenizer's consumers strip
18//! the surrounding quotes and un-double before calling this.
19
20use std::borrow::Cow;
21
22/// Decode IFC STEP string escapes to UTF-8.
23///
24/// Returns the input borrowed and untouched when it contains no backslash, so
25/// the common case (plain names, GUIDs, enums) is allocation-free.
26///
27/// This handles only backslash escapes. The `''` doubled-quote escape is
28/// collapsed by the STEP tokenizer's consumers (they strip the surrounding
29/// quotes and un-double), so decoding must not touch quotes or it would
30/// double-collapse those paths.
31pub fn decode_ifc_string(s: &str) -> Cow<'_, str> {
32    if !s.as_bytes().contains(&b'\\') {
33        return Cow::Borrowed(s);
34    }
35
36    let bytes = s.as_bytes();
37    let n = bytes.len();
38    let mut out = String::with_capacity(n);
39    let mut i = 0;
40
41    while i < n {
42        if bytes[i] != b'\\' {
43            // Copy one whole UTF-8 character; `i` is always on a char boundary
44            // because every escape marker is ASCII.
45            let ch = s[i..].chars().next().unwrap();
46            out.push(ch);
47            i += ch.len_utf8();
48            continue;
49        }
50
51        // `\PC\` code-page directive: consume four bytes and drop.
52        if i + 3 < n && bytes[i + 1] == b'P' && bytes[i + 3] == b'\\' {
53            i += 4;
54            continue;
55        }
56
57        // `\S\C`: byte value is the code point of `C` plus 128. Read `C` as a
58        // whole char and advance by its UTF-8 length so a malformed multi-byte
59        // `C` can't leave `i` mid-character (which would panic the next slice).
60        if i + 3 < n && bytes[i + 1] == b'S' && bytes[i + 2] == b'\\' {
61            let c = s[i + 3..].chars().next().unwrap();
62            let code = c as u32 + 128;
63            out.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
64            i += 3 + c.len_utf8();
65            continue;
66        }
67
68        // `\X\HH`: a single ISO-8859-1 byte.
69        if i + 4 < n && bytes[i + 1] == b'X' && bytes[i + 2] == b'\\' {
70            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 3]), hex_val(bytes[i + 4])) {
71                let code = ((hi << 4) | lo) as u32;
72                out.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
73                i += 5;
74                continue;
75            }
76        }
77
78        // `\X2\HHHH..\X0\`: UTF-16 code units (decoded as a unit, so surrogate
79        // pairs spanning two groups combine correctly).
80        if starts_with(bytes, i, b"\\X2\\") {
81            if let Some(end) = find(bytes, i + 4, b"\\X0\\") {
82                let hex = &s[i + 4..end];
83                if !hex.is_empty()
84                    && hex.len().is_multiple_of(4)
85                    && hex.bytes().all(|c| c.is_ascii_hexdigit())
86                {
87                    let units: Vec<u16> = (0..hex.len())
88                        .step_by(4)
89                        .map(|j| u16::from_str_radix(&hex[j..j + 4], 16).unwrap())
90                        .collect();
91                    out.push_str(&String::from_utf16_lossy(&units));
92                    i = end + 4;
93                    continue;
94                }
95            }
96        }
97
98        // `\X4\HHHHHHHH..\X0\`: Unicode scalar values.
99        if starts_with(bytes, i, b"\\X4\\") {
100            if let Some(end) = find(bytes, i + 4, b"\\X0\\") {
101                let hex = &s[i + 4..end];
102                if !hex.is_empty()
103                    && hex.len().is_multiple_of(8)
104                    && hex.bytes().all(|c| c.is_ascii_hexdigit())
105                {
106                    for j in (0..hex.len()).step_by(8) {
107                        let v = u32::from_str_radix(&hex[j..j + 8], 16).unwrap();
108                        out.push(char::from_u32(v).unwrap_or('\u{FFFD}'));
109                    }
110                    i = end + 4;
111                    continue;
112                }
113            }
114        }
115
116        // Unknown escape: keep the backslash and advance one byte.
117        out.push('\\');
118        i += 1;
119    }
120
121    Cow::Owned(out)
122}
123
124/// Encode a UTF-8 string back to IFC STEP escapes. Inverse of
125/// [`decode_ifc_string`] for the canonical (non-overlong) forms; kept for STEP
126/// writers and round-trip tests.
127///
128/// Printable ASCII is preserved; everything else (and backslash) is escaped as
129/// `\X\HH`, `\X2\HHHH\X0\`, or `\X4\HHHHHHHH\X0\` by code point.
130pub fn encode_ifc_string(s: &str) -> Cow<'_, str> {
131    if s.bytes().all(|b| (0x20..=0x7E).contains(&b) && b != b'\\') {
132        return Cow::Borrowed(s);
133    }
134
135    let mut out = String::with_capacity(s.len());
136    for ch in s.chars() {
137        let cp = ch as u32;
138        if (0x20..=0x7E).contains(&cp) && ch != '\\' {
139            out.push(ch);
140        } else if cp <= 0xFF {
141            out.push_str(&format!("\\X\\{cp:02X}"));
142        } else if cp <= 0xFFFF {
143            out.push_str(&format!("\\X2\\{cp:04X}\\X0\\"));
144        } else {
145            out.push_str(&format!("\\X4\\{cp:08X}\\X0\\"));
146        }
147    }
148    Cow::Owned(out)
149}
150
151#[inline]
152fn hex_val(b: u8) -> Option<u8> {
153    match b {
154        b'0'..=b'9' => Some(b - b'0'),
155        b'a'..=b'f' => Some(b - b'a' + 10),
156        b'A'..=b'F' => Some(b - b'A' + 10),
157        _ => None,
158    }
159}
160
161#[inline]
162fn starts_with(bytes: &[u8], at: usize, pat: &[u8]) -> bool {
163    bytes.len() >= at + pat.len() && &bytes[at..at + pat.len()] == pat
164}
165
166fn find(bytes: &[u8], from: usize, pat: &[u8]) -> Option<usize> {
167    if pat.is_empty() || from + pat.len() > bytes.len() {
168        return None;
169    }
170    bytes[from..]
171        .windows(pat.len())
172        .position(|w| w == pat)
173        .map(|p| from + p)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn no_backslash_is_borrowed_and_unchanged() {
182        assert!(matches!(decode_ifc_string("Hello World"), Cow::Borrowed(_)));
183        // A typical base64 IFC GUID contains no backslash.
184        assert_eq!(decode_ifc_string("3Bvg7$qHb0gP37$Qz2vN1k"), "3Bvg7$qHb0gP37$Qz2vN1k");
185    }
186
187    #[test]
188    fn decodes_x2_bmp() {
189        assert_eq!(decode_ifc_string(r"Br\X2\00FC\X0\cke"), "Br\u{FC}cke");
190    }
191
192    #[test]
193    fn decodes_x2_surrogate_pair() {
194        assert_eq!(decode_ifc_string(r"\X2\D83DDE00\X0\"), "\u{1F600}");
195    }
196
197    #[test]
198    fn decodes_x4_astral() {
199        assert_eq!(decode_ifc_string(r"\X4\0001F600\X0\"), "\u{1F600}");
200    }
201
202    #[test]
203    fn decodes_x_and_s() {
204        assert_eq!(decode_ifc_string(r"\X\E9"), "\u{E9}");
205        assert_eq!(decode_ifc_string(r"\S\a"), "\u{E1}");
206    }
207
208    #[test]
209    fn drops_code_page_directive() {
210        assert_eq!(decode_ifc_string(r"\PA\Hello"), "Hello");
211    }
212
213    #[test]
214    fn keeps_unknown_escape() {
215        assert_eq!(decode_ifc_string(r"a\Qb"), r"a\Qb");
216        // Malformed (no terminator) is passed through, not panicked on.
217        assert_eq!(decode_ifc_string(r"\X2\00FC"), r"\X2\00FC");
218    }
219
220    #[test]
221    fn s_escape_before_multibyte_char_does_not_panic() {
222        // A malformed `\S\` followed by a multi-byte UTF-8 char must not leave
223        // the cursor mid-character (previously panicked via a non-boundary
224        // slice, aborting the whole wasm instance under panic=abort).
225        let _ = decode_ifc_string("\\S\\\u{00E9}tail");
226        let _ = decode_ifc_string("x\\S\\\u{1F600}y");
227        // The canonical single-ASCII form is unchanged.
228        assert_eq!(decode_ifc_string(r"\S\a"), "\u{E1}");
229    }
230
231    #[test]
232    fn round_trips_through_encode() {
233        for s in ["plain", "Br\u{FC}cke", "\u{1F600}", "a\u{E9}b"] {
234            assert_eq!(decode_ifc_string(&encode_ifc_string(s)), s);
235        }
236    }
237}