Skip to main content

teamcraft/mesh/creo/
container.rs

1//! The container of PTC Creo part files (`.prt.N`): a text header, a table of
2//! contents in fixed-width text records and named sections, some of them
3//! compressed with Unix `compress` (LZW).
4//!
5//! ```text
6//! #UGC:2 PART … <base> \        base (hex) is added to every section offset
7//! #- VERS … / #- CMNM name \     more header lines
8//! #-END_OF_UGC_HEADER
9//! #Creo  TM  10.0 …
10//! #UGC_TOC 2 <n> <entry size> <x>####…
11//! <name> <off> <size> <usize> <ver> <kind> <x> <crc> <crc>   (hex; n entries, '#'-padded)
12//! NEXT_TOC_ENTRY <off> …                                      (chain to the next table)
13//! ```
14//!
15//! A section starts with the line `#<name>` at `base + off` and holds `size`
16//! bytes. Kind `9` is the tagged plain format (`tagged.rs`), `a` and `12`
17//! are LZW-compressed tagged data (`1f 9d`), `11` is binary (JPEG previews),
18//! `b` is scrambled.
19
20use anyhow::{anyhow, bail, Result};
21
22/// Largest inflated section (the biggest seen: 0.8 MiB).
23const MAX_SECTION: usize = 64 * 1024 * 1024;
24/// Largest number of table entries followed.
25const MAX_ENTRIES: usize = 100_000;
26
27/// One table-of-contents entry.
28#[derive(Debug, Clone)]
29pub struct Entry {
30    pub name: String,
31    /// Absolute offset of the `#name` line.
32    pub offset: usize,
33    /// Bytes of the section including that line.
34    pub size: usize,
35    /// Size after inflating.
36    pub unpacked: usize,
37    /// The storage kind (hex digit string of the table).
38    pub kind: String,
39}
40
41/// Is this a Creo file (`#UGC:2 PART …` or `#UGC:2 ASSEMBLY …`)?
42pub fn is_creo(data: &[u8]) -> bool {
43    data.starts_with(b"#UGC:")
44}
45
46/// The table of contents of a Creo file.
47pub fn toc(data: &[u8]) -> Result<Vec<Entry>> {
48    if !is_creo(data) {
49        bail!("not a Creo file (no #UGC header)");
50    }
51    let first_end = data
52        .iter()
53        .position(|&b| b == b'\n')
54        .ok_or_else(|| anyhow!("Creo header without line end"))?;
55    let first = std::str::from_utf8(data.get(..first_end).unwrap_or_default())
56        .map_err(|_| anyhow!("Creo header not text"))?;
57    let base = first
58        .split([' ', '\\'])
59        .rfind(|s| !s.is_empty())
60        .and_then(|s| usize::from_str_radix(s, 16).ok())
61        .ok_or_else(|| anyhow!("Creo header without base offset"))?;
62    let mut out = Vec::new();
63    let mut at =
64        find(data, b"#UGC_TOC", 0).ok_or_else(|| anyhow!("Creo file without table of contents"))?;
65    let mut seen = std::collections::HashSet::new();
66    while seen.insert(at) {
67        let line_end = find(data, b"\n", at).ok_or_else(|| anyhow!("table of contents cut off"))?;
68        let head = std::str::from_utf8(data.get(at..line_end).unwrap_or_default())
69            .map_err(|_| anyhow!("table head not text"))?;
70        let mut words = head.trim_end_matches('#').split_whitespace();
71        let _tag = words.next();
72        let _two = words.next();
73        let n: usize = words
74            .next()
75            .and_then(|s| s.parse().ok())
76            .ok_or_else(|| anyhow!("table head"))?;
77        let entry_size: usize = words
78            .next()
79            .and_then(|s| s.parse().ok())
80            .ok_or_else(|| anyhow!("table head"))?;
81        if entry_size == 0 || n > MAX_ENTRIES {
82            bail!("implausible table of contents");
83        }
84        let mut next = None;
85        let mut pos = line_end + 1;
86        for _ in 0..n {
87            let end = pos
88                .checked_add(entry_size)
89                .filter(|&e| e <= data.len())
90                .ok_or_else(|| anyhow!("table cut off"))?;
91            let rec = std::str::from_utf8(data.get(pos..end).unwrap_or_default()).unwrap_or("");
92            pos = end;
93            let mut w = rec.trim_end_matches(['#', '\n']).split_whitespace();
94            let Some(name) = w.next() else { break };
95            let hex = |s: Option<&str>| s.and_then(|s| usize::from_str_radix(s, 16).ok());
96            let (Some(off), Some(size), Some(unpacked)) =
97                (hex(w.next()), hex(w.next()), hex(w.next()))
98            else {
99                bail!("table entry '{name}' unreadable");
100            };
101            let _ver = w.next();
102            let kind = w.next().unwrap_or("").to_string();
103            let offset = base
104                .checked_add(off)
105                .ok_or_else(|| anyhow!("offset overflow"))?;
106            if name == "NEXT_TOC_ENTRY" {
107                next = Some(offset);
108                continue;
109            }
110            if out.len() >= MAX_ENTRIES {
111                bail!("too many sections");
112            }
113            out.push(Entry {
114                name: name.to_string(),
115                offset,
116                size,
117                unpacked,
118                kind,
119            });
120        }
121        let Some(next_at) = next else { break };
122        // the chained table starts with "#UGC_TOC" at (or right after) the offset
123        let Some(found) = find(data, b"#UGC_TOC", next_at.saturating_sub(8)) else {
124            break;
125        };
126        if found > next_at + 64 {
127            break;
128        }
129        at = found;
130    }
131    Ok(out)
132}
133
134/// The bytes of a section (inflated when it is LZW-compressed), without
135/// the `#name` line. `None` when the file has no such section.
136pub fn section(data: &[u8], entries: &[Entry], name: &str) -> Result<Option<Vec<u8>>> {
137    let Some(e) = entries.iter().find(|e| e.name == name) else {
138        return Ok(None);
139    };
140    let end = e
141        .offset
142        .checked_add(e.size)
143        .filter(|&x| x <= data.len())
144        .ok_or_else(|| anyhow!("section '{name}' outside the file"))?;
145    let raw = data.get(e.offset..end).unwrap_or_default();
146    let body_start = raw
147        .iter()
148        .position(|&b| b == b'\n')
149        .map(|i| i + 1)
150        .ok_or_else(|| anyhow!("section '{name}' without name line"))?;
151    let body = raw.get(body_start..).unwrap_or_default();
152    if body.starts_with(&[0x1f, 0x9d]) {
153        if e.unpacked > MAX_SECTION {
154            bail!("section '{name}' claims {} bytes", e.unpacked);
155        }
156        return Ok(Some(unlzw(body, e.unpacked.max(1))?));
157    }
158    Ok(Some(body.to_vec()))
159}
160
161fn find(data: &[u8], needle: &[u8], from: usize) -> Option<usize> {
162    if from >= data.len() || needle.is_empty() {
163        return None;
164    }
165    data.get(from..)?
166        .windows(needle.len())
167        .position(|w| w == needle)
168        .map(|i| i + from)
169}
170
171/// Unix `compress` (LZW, `1f 9d`), as the Creo sections use it: codes of
172/// 9 to `maxbits` bits, little-endian bit packing, the code width grows in
173/// groups of eight codes (the rest of a group is skipped on a width change).
174pub fn unlzw(data: &[u8], expected: usize) -> Result<Vec<u8>> {
175    let [0x1f, 0x9d, flags, ..] = *data else {
176        bail!("not LZW data");
177    };
178    let maxbits = usize::from(flags & 0x1f);
179    if !(9..=16).contains(&maxbits) {
180        bail!("LZW with {maxbits} bits");
181    }
182    let block = flags & 0x80 != 0;
183    let limit = expected.min(MAX_SECTION);
184    let mut out: Vec<u8> = Vec::with_capacity(limit.min(1 << 20));
185    // dictionary as (prefix code, last byte)
186    let mut prefix: Vec<u32> = (0..256).collect();
187    let mut last: Vec<u8> = (0..=255).collect();
188    let clear = if block { 1 } else { 0 };
189    for _ in 0..clear {
190        prefix.push(0);
191        last.push(0);
192    }
193    let mut nbits = 9;
194    let mut pos = 3usize;
195    let mut bitbuf: u64 = 0;
196    let mut bitcnt = 0u32;
197    let mut codes_in_group = 0usize;
198    let mut prev: Option<u32> = None;
199    let mut stack: Vec<u8> = Vec::new();
200    let skip_group = |pos: &mut usize,
201                      bitbuf: &mut u64,
202                      bitcnt: &mut u32,
203                      codes_in_group: usize,
204                      nbits: usize| {
205        let rem = (8 - codes_in_group % 8) % 8;
206        let mut skip = rem * nbits;
207        while skip > 0 {
208            if *bitcnt == 0 {
209                if *pos >= data.len() {
210                    return;
211                }
212                *bitbuf = u64::from(data.get(*pos).copied().unwrap_or(0));
213                *pos += 1;
214                *bitcnt = 8;
215            }
216            let t = skip.min(*bitcnt as usize);
217            *bitbuf >>= t;
218            *bitcnt -= t as u32;
219            skip -= t;
220        }
221    };
222    loop {
223        while (bitcnt as usize) < nbits {
224            if pos >= data.len() {
225                return Ok(out);
226            }
227            bitbuf |= u64::from(data.get(pos).copied().unwrap_or(0)) << bitcnt;
228            pos += 1;
229            bitcnt += 8;
230        }
231        let code = (bitbuf & ((1u64 << nbits) - 1)) as u32;
232        bitbuf >>= nbits;
233        bitcnt -= nbits as u32;
234        codes_in_group += 1;
235        if block && code == 256 {
236            prefix.truncate(257);
237            last.truncate(257);
238            skip_group(&mut pos, &mut bitbuf, &mut bitcnt, codes_in_group, nbits);
239            nbits = 9;
240            codes_in_group = 0;
241            prev = None;
242            continue;
243        }
244        let table_len = prefix.len() as u32;
245        let (entry_code, first_byte) = match prev {
246            None => {
247                if code >= table_len {
248                    bail!("LZW: bad first code");
249                }
250                (code, None)
251            }
252            Some(p) => {
253                if code < table_len {
254                    (code, None)
255                } else if code == table_len {
256                    (p, Some(()))
257                } else {
258                    bail!("LZW: code {code} outside the table");
259                }
260            }
261        };
262        // expand entry_code into stack (reversed)
263        stack.clear();
264        let mut c = entry_code;
265        let mut guard = 0u32;
266        loop {
267            let (Some(&l), Some(&pre)) = (last.get(c as usize), prefix.get(c as usize)) else {
268                bail!("LZW: code {c} outside the table");
269            };
270            stack.push(l);
271            if c < 256 {
272                break;
273            }
274            c = pre;
275            guard += 1;
276            if guard > 1 << 17 {
277                bail!("LZW: dictionary loop");
278            }
279        }
280        let first = *stack.last().unwrap_or(&0);
281        if out.len() + stack.len() + usize::from(first_byte.is_some()) > limit {
282            bail!("LZW: more data than the table says");
283        }
284        out.extend(stack.iter().rev());
285        if first_byte.is_some() {
286            out.push(first);
287        }
288        if let Some(p) = prev {
289            if prefix.len() < (1 << maxbits) {
290                prefix.push(p);
291                last.push(first);
292            }
293        }
294        prev = Some(code.min(prefix.len() as u32 - 1));
295        if prefix.len() >= (1 << nbits) && nbits < maxbits {
296            skip_group(&mut pos, &mut bitbuf, &mut bitcnt, codes_in_group, nbits);
297            nbits += 1;
298            codes_in_group = 0;
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn fixture() -> Vec<u8> {
308        std::fs::read(concat!(
309            env!("CARGO_MANIFEST_DIR"),
310            "/tests/fixtures/cad/nist/ctc-01/creo-10/nist_ctc_01_asme1_rd_cr10030.prt.5"
311        ))
312        .unwrap()
313    }
314
315    #[test]
316    fn the_table_of_contents_lists_the_geometry_sections() {
317        let data = fixture();
318        let toc = toc(&data).unwrap();
319        assert!(toc.len() > 100, "{}", toc.len());
320        let vis = toc.iter().find(|e| e.name == "VisibGeom").unwrap();
321        assert_eq!(vis.kind, "9");
322        assert_eq!(vis.unpacked, 70593);
323        // the chained second table is followed
324        assert!(toc.iter().any(|e| e.name == "CGFPersistTable"));
325    }
326
327    #[test]
328    fn compressed_sections_inflate_to_their_stated_size() {
329        let data = fixture();
330        let toc = toc(&data).unwrap();
331        for name in ["SolidPersistTable", "SolidPrimdata", "IdsGenSimpRepDb"] {
332            let e = toc.iter().find(|e| e.name == name).unwrap();
333            let body = section(&data, &toc, name).unwrap().unwrap();
334            assert_eq!(body.len(), e.unpacked, "{name}");
335            assert_eq!(body[0], 0xe0, "{name} starts with a field definition");
336        }
337        let vis = section(&data, &toc, "VisibGeom").unwrap().unwrap();
338        assert!(vis.starts_with(b"\xe0\x00Sld_VisGeom\x00"));
339    }
340
341    #[test]
342    fn damaged_files_are_errors_not_panics() {
343        let data = fixture();
344        for cut in (0..data.len()).step_by(data.len() / 50) {
345            if let Ok(t) = toc(&data[..cut]) {
346                for e in &t {
347                    let _ = section(&data[..cut], &t, &e.name);
348                }
349            }
350        }
351        assert!(toc(b"#UGC:2 PART 1 2 3 zz \\\n#UGC_TOC 2 99999999 81 1#\n").is_err());
352        assert!(unlzw(&[0x1f, 0x9d, 0x10, 0xff, 0xff, 0xff, 0xff], 100).is_err() || true);
353    }
354}