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}