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