1use std::collections::BTreeMap;
5
6use cadmpeg_core::{CodecError, ContainerEntry, ContainerSummary};
7use cadmpeg_ir::codec::{
8 CodecBackend, Confidence, DecodeOptions, DecodeResult, EncodeInput, Encoder, ExportPlan,
9};
10use cadmpeg_ir::FidelityResolution;
11
12use crate::archive;
13use crate::export::write_step;
14use crate::options::{StepSchema, StepWriteOptions};
15use crate::parse;
16use crate::reader;
17
18#[derive(Debug, Clone, Default)]
20pub struct StepCodec {
21 pub options: StepWriteOptions,
23}
24
25impl Encoder for StepCodec {
26 fn id(&self) -> &'static str {
27 "step"
28 }
29
30 fn plan<'a>(&self, input: EncodeInput<'a>) -> Result<ExportPlan<'a>, CodecError> {
31 let mut bytes = Vec::new();
32 let mut report =
33 write_step(input.ir, &mut bytes, &self.options).map_err(CodecError::from)?;
34 report.fidelity = if input.fidelity.is_some() {
38 FidelityResolution::NotConsumed
39 } else {
40 FidelityResolution::NotProvided
41 };
42 Ok(ExportPlan::buffered(report, bytes))
43 }
44}
45
46impl CodecBackend for StepCodec {
47 fn id(&self) -> &'static str {
48 "step"
49 }
50
51 fn detect(&self, prefix: &[u8]) -> Confidence {
52 if starts_with_step_magic(prefix) {
53 Confidence::High
54 } else if archive::has_root_marker(prefix)
55 || is_part26_hdf5(prefix)
56 || is_part28_xml(prefix)
57 || is_ap242_bo_model_xml(prefix)
58 {
59 Confidence::Medium
60 } else if archive::has_zip_magic(prefix) {
61 Confidence::Low
62 } else {
63 Confidence::No
64 }
65 }
66
67 fn inspect_impl(
73 &self,
74 ctx: &cadmpeg_core::decode::DecodeContext<'_>,
75 root: cadmpeg_core::decode::View<'_>,
76 ) -> Result<ContainerSummary, CodecError> {
77 let bytes = root.window();
78 if archive::has_zip_magic(bytes) {
79 return inspect_zip(ctx, root);
80 }
81 refuse_alternate_encoding(bytes)?;
82 if self.detect(bytes) == Confidence::No {
83 return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
84 }
85 let (mut exchange, diagnostics) = parse::parse_with_context(bytes, ctx)?;
86 let (decoded, opaque_offsets) =
87 reader::analyze_exchange(bytes, &mut exchange, &diagnostics, Some(ctx))?;
88 let mut entries = vec![ContainerEntry {
89 name: "HEADER".into(),
90 role: "metadata".into(),
91 compression: "none".into(),
92 compressed_size: 0,
93 uncompressed_size: 0,
94 attributes: BTreeMap::default(),
95 }];
96 if !exchange.anchors.is_empty() {
97 let mut attributes = std::collections::BTreeMap::new();
98 attributes.insert("anchor_count".into(), exchange.anchors.len().to_string());
99 entries.push(ContainerEntry {
100 name: "ANCHOR".into(),
101 role: "in_file_anchors".into(),
102 compression: "none".into(),
103 compressed_size: 0,
104 uncompressed_size: 0,
105 attributes,
106 });
107 }
108 if !exchange.references.is_empty() {
109 let mut attributes = std::collections::BTreeMap::new();
110 attributes.insert(
111 "external_count".into(),
112 exchange.references.len().to_string(),
113 );
114 attributes.insert(
115 "external_uris".into(),
116 exchange
117 .references
118 .iter()
119 .map(|entry| entry.uri.as_str())
120 .collect::<Vec<_>>()
121 .join(","),
122 );
123 entries.push(ContainerEntry {
124 name: "REFERENCE".into(),
125 role: "external_references".into(),
126 compression: "none".into(),
127 compressed_size: 0,
128 uncompressed_size: 0,
129 attributes,
130 });
131 }
132 for (index, section) in exchange.data.iter().enumerate() {
133 let mut counts = std::collections::BTreeMap::<String, usize>::new();
134 for id in §ion.records {
135 if !opaque_offsets.contains(&exchange.records[id].span.start) {
136 continue;
137 }
138 for partial in &exchange.records[id].partials {
139 *counts.entry(partial.name.clone()).or_default() += 1;
140 }
141 }
142 let unknown = counts
143 .iter()
144 .map(|(name, count)| format!("{name}:{count}"))
145 .collect::<Vec<_>>()
146 .join(",");
147 let mut attributes = std::collections::BTreeMap::new();
148 attributes.insert("entity_count".into(), section.records.len().to_string());
149 attributes.insert("unknown_entities".into(), unknown);
150 entries.push(ContainerEntry {
151 name: format!("DATA[{index}]"),
152 role: "entity_records".into(),
153 compression: "none".into(),
154 compressed_size: 0,
155 uncompressed_size: 0,
156 attributes,
157 });
158 }
159 let external_dependencies = decoded
160 .report()
161 .notes
162 .iter()
163 .filter(|note| {
164 note.starts_with("external document ") || note.starts_with("external source ")
165 })
166 .cloned()
167 .collect::<Vec<_>>();
168 if !external_dependencies.is_empty() {
169 let mut attributes = std::collections::BTreeMap::new();
170 attributes.insert(
171 "dependency_count".into(),
172 external_dependencies.len().to_string(),
173 );
174 attributes.insert("dependencies".into(), external_dependencies.join(","));
175 entries.push(ContainerEntry {
176 name: "EXTERNAL_DEPENDENCIES".into(),
177 role: "external_references".into(),
178 compression: "none".into(),
179 compressed_size: 0,
180 uncompressed_size: 0,
181 attributes,
182 });
183 }
184 for (index, _) in exchange.signatures.iter().enumerate() {
185 entries.push(ContainerEntry {
186 name: if index == 0 {
187 "SIGNATURE".into()
188 } else {
189 format!("SIGNATURE[{index}]")
190 },
191 role: "signature".into(),
192 compression: "none".into(),
193 compressed_size: 0,
194 uncompressed_size: 0,
195 attributes: BTreeMap::default(),
196 });
197 }
198 let identifiers = reader::schema_identifiers(&exchange);
199 let schema = if identifiers.is_empty() {
200 "unspecified".into()
201 } else {
202 identifiers.join(",")
203 };
204 let edition = identifiers
205 .first()
206 .and_then(|identifier| StepSchema::ap242_edition(identifier))
207 .unwrap_or("edition unspecified");
208 let mut notes = vec![format!("schema {schema}; {edition}")];
209 notes.extend(diagnostics.into_iter().map(|diagnostic| diagnostic.message));
210 Ok(ContainerSummary {
211 format: "step".into(),
212 container_kind: "iso-10303-21-clear-text".into(),
213 entries,
214 notes,
215 })
216 }
217
218 fn decode_impl(
219 &self,
220 ctx: &cadmpeg_core::decode::DecodeContext<'_>,
221 root: cadmpeg_core::decode::View<'_>,
222 ) -> Result<DecodeResult, CodecError> {
223 let bytes = root.window();
224 if archive::has_zip_magic(bytes) {
225 return decode_zip(ctx, root);
226 }
227 refuse_alternate_encoding(bytes)?;
228 if self.detect(bytes) == Confidence::No {
229 return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
230 }
231 reader::decode(
232 bytes,
233 DecodeOptions {
234 container_only: ctx.container_only(),
235 policy: *ctx.policy(),
236 },
237 ctx,
238 )
239 }
240}
241
242fn starts_with_step_magic(bytes: &[u8]) -> bool {
243 let mut at = 0;
244 loop {
245 while bytes
246 .get(at)
247 .is_some_and(|byte| byte.is_ascii_control() || *byte == b' ')
248 {
249 at += 1;
250 }
251 if bytes.get(at..at + 2) == Some(b"/*") {
252 at += 2;
253 let Some(relative_end) = bytes[at..].windows(2).position(|window| window == b"*/")
254 else {
255 return false;
256 };
257 at += relative_end + 2;
258 continue;
259 }
260 if bytes
261 .get(at..at + 3)
262 .is_some_and(|prefix| prefix == b"\\N\\" || prefix == b"\\F\\")
263 {
264 at += 3;
265 continue;
266 }
267 break;
268 }
269 for &expected_byte in b"ISO-10303-21;" {
270 while bytes.get(at).is_some_and(u8::is_ascii_control) {
271 at += 1;
272 }
273 if !bytes
274 .get(at)
275 .is_some_and(|byte| byte.eq_ignore_ascii_case(&expected_byte))
276 {
277 return false;
278 }
279 at += 1;
280 }
281 true
282}
283
284fn inspect_zip(
285 ctx: &cadmpeg_core::decode::DecodeContext<'_>,
286 root: cadmpeg_core::decode::View<'_>,
287) -> Result<ContainerSummary, CodecError> {
288 let (archive, root_view) = archive::open_root(ctx, root)?;
289 let root_summary = StepCodec::default().inspect_impl(ctx, root_view)?;
290 let resource_notes = archive::root_reference_notes(&archive, root_view.window())?;
291 let entry_count = archive.entries().len();
292 let root_entry = archive
293 .entry(archive::ROOT_NAME)
294 .expect("validated STEP ZIP root");
295 let root_data_offset = root_entry.data_start;
296 let logical_entries = root_summary
297 .entries
298 .iter()
299 .map(|entry| entry.name.as_str())
300 .collect::<Vec<_>>()
301 .join(",");
302 let mut entries = archive.container_entries(archive::classify_entry);
303 if let Some(root_entry) = entries
304 .iter_mut()
305 .find(|entry| entry.name == archive::ROOT_NAME)
306 {
307 root_entry
308 .attributes
309 .insert("logical_sections".into(), logical_entries);
310 }
311 let mut notes = vec![
312 format!("root {}", archive::ROOT_NAME),
313 format!("archive entries={entry_count}; root data offset={root_data_offset}"),
314 ];
315 notes.extend(root_summary.notes);
316 notes.extend(resource_notes);
317 Ok(ContainerSummary {
318 format: "step".into(),
319 container_kind: "iso-10303-21-zip".into(),
320 entries,
321 notes,
322 })
323}
324
325fn decode_zip(
326 ctx: &cadmpeg_core::decode::DecodeContext<'_>,
327 root: cadmpeg_core::decode::View<'_>,
328) -> Result<DecodeResult, CodecError> {
329 let (archive, root_view) = archive::open_root(ctx, root)?;
330 let resource_notes = archive::root_reference_notes(&archive, root_view.window())?;
331 let entry_count = archive.entries().len();
332 let root_entry = archive
333 .entry(archive::ROOT_NAME)
334 .expect("validated STEP ZIP root");
335 let root_data_offset = root_entry.data_start;
336 let mut result = reader::decode(
337 root_view.window(),
338 DecodeOptions {
339 container_only: ctx.container_only(),
340 policy: *ctx.policy(),
341 },
342 ctx,
343 )?;
344 if let Some(source) = &mut result.ir_mut().source {
345 source
346 .attributes
347 .insert("container_kind".into(), "iso-10303-21-zip".into());
348 source
349 .attributes
350 .insert("archive_root".into(), archive::ROOT_NAME.into());
351 source
352 .attributes
353 .insert("archive_entries".into(), entry_count.to_string());
354 source.attributes.insert(
355 "archive_root_data_offset".into(),
356 root_data_offset.to_string(),
357 );
358 }
359 result.report_mut().notes.push(format!(
360 "container root {}; archive entries={entry_count}",
361 archive::ROOT_NAME
362 ));
363 result.report_mut().notes.extend(resource_notes);
364 Ok(result)
365}
366
367fn refuse_alternate_encoding(bytes: &[u8]) -> Result<(), CodecError> {
368 if is_part26_hdf5(bytes) {
380 return Err(CodecError::NotImplemented(
381 "STEP Part 26 binary/HDF5 encoding".into(),
382 ));
383 }
384 if is_part28_xml(bytes) {
385 return Err(CodecError::NotImplemented(
386 "STEP Part 28 XML encoding".into(),
387 ));
388 }
389 if is_ap242_bo_model_xml(bytes) {
390 return Err(CodecError::NotImplemented(
391 "AP242 BO-Model XML sidecar".into(),
392 ));
393 }
394 Ok(())
395}
396
397fn is_part26_hdf5(bytes: &[u8]) -> bool {
398 const SIGNATURE: &[u8] = b"\x89HDF\r\n\x1a\n";
399 if bytes.starts_with(SIGNATURE) {
400 return true;
401 }
402
403 let mut offset = 512;
404 while offset < bytes.len() {
405 if bytes[offset..].starts_with(SIGNATURE) {
406 return true;
407 }
408 let Some(next) = offset.checked_mul(2) else {
409 break;
410 };
411 offset = next;
412 }
413 false
414}
415
416fn is_part28_xml(bytes: &[u8]) -> bool {
417 let bytes = &bytes[..bytes.len().min(4096)];
418 let Some((name, attributes)) = xml_root_start_tag(bytes) else {
419 return false;
420 };
421 let local_name = name
422 .iter()
423 .rposition(|byte| *byte == b':')
424 .map_or(name, |separator| &name[separator + 1..]);
425 if local_name.eq_ignore_ascii_case(b"iso_10303_28")
426 || ascii_starts_with(local_name, b"iso_10303_28_")
427 {
428 return true;
429 }
430
431 PART28_COMMON_NAMESPACES
436 .iter()
437 .any(|namespace| has_namespace_value(attributes, namespace))
438}
439
440const PART28_COMMON_NAMESPACES: [&[u8]; 3] = [
441 b"urn:oid:1.0.10303.28.2.1.1",
442 b"urn:iso:std:iso:10303:-28:ed-2:tech:XMLschema:common",
443 b"urn:iso.org:standard:10303:part(28):version(2):xmlschema:common",
444];
445
446fn ascii_starts_with(value: &[u8], prefix: &[u8]) -> bool {
447 value.len() >= prefix.len()
448 && value[..prefix.len()]
449 .iter()
450 .zip(prefix)
451 .all(|(value, prefix)| value.eq_ignore_ascii_case(prefix))
452}
453
454fn xml_root_start_tag(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
455 let mut cursor = if bytes.starts_with(b"\xef\xbb\xbf") {
456 3
457 } else {
458 0
459 };
460 loop {
461 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
462 cursor += 1;
463 }
464 if bytes.get(cursor) != Some(&b'<') {
465 return None;
466 }
467 if bytes.get(cursor + 1) == Some(&b'?') {
468 let end = bytes
469 .get(cursor + 2..)?
470 .windows(2)
471 .position(|window| window == b"?>")?
472 + cursor
473 + 2;
474 cursor = end + 2;
475 continue;
476 }
477 if bytes.get(cursor + 1..cursor + 4) == Some(b"!--") {
478 let end = bytes
479 .get(cursor + 4..)?
480 .windows(3)
481 .position(|window| window == b"-->")?
482 + cursor
483 + 4;
484 cursor = end + 3;
485 continue;
486 }
487 if bytes.get(cursor + 1) == Some(&b'!') {
488 let end = bytes
489 .get(cursor + 2..)?
490 .iter()
491 .position(|byte| *byte == b'>')?
492 + cursor
493 + 2;
494 cursor = end + 1;
495 continue;
496 }
497 break;
498 }
499
500 let tag_end = find_xml_tag_end(bytes, cursor + 1)?;
501 let mut name_end = cursor + 1;
502 while bytes
503 .get(name_end)
504 .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'/' && *byte != b'>')
505 {
506 name_end += 1;
507 }
508 if name_end == cursor + 1 {
509 return None;
510 }
511 Some((&bytes[cursor + 1..name_end], &bytes[name_end..tag_end]))
512}
513
514fn find_xml_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
515 let mut quote = None;
516 while let Some(byte) = bytes.get(cursor).copied() {
517 match quote {
518 Some(delimiter) if byte == delimiter => quote = None,
519 Some(_) => {}
520 None if byte == b'\'' || byte == b'"' => quote = Some(byte),
521 None if byte == b'>' => return Some(cursor),
522 None => {}
523 }
524 cursor += 1;
525 }
526 None
527}
528
529fn has_namespace_value(attributes: &[u8], expected: &[u8]) -> bool {
530 xml_attribute_value(attributes, |name, value| {
531 (name == b"xmlns" || name.starts_with(b"xmlns:")) && value == expected
532 })
533 .is_some()
534}
535
536fn xml_attribute_value(
537 attributes: &[u8],
538 mut matches: impl FnMut(&[u8], &[u8]) -> bool,
539) -> Option<&[u8]> {
540 let mut cursor = 0;
541 while cursor < attributes.len() {
542 while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
543 cursor += 1;
544 }
545 if attributes.get(cursor).is_none_or(|byte| *byte == b'/') {
546 break;
547 }
548 let name_start = cursor;
549 while attributes
550 .get(cursor)
551 .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'=' && *byte != b'/')
552 {
553 cursor += 1;
554 }
555 let name = &attributes[name_start..cursor];
556 while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
557 cursor += 1;
558 }
559 if attributes.get(cursor) != Some(&b'=') {
560 return None;
561 }
562 cursor += 1;
563 while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
564 cursor += 1;
565 }
566 let delimiter = *attributes.get(cursor)?;
567 if delimiter != b'\'' && delimiter != b'"' {
568 return None;
569 }
570 cursor += 1;
571 let value_start = cursor;
572 while attributes
573 .get(cursor)
574 .is_some_and(|byte| *byte != delimiter)
575 {
576 cursor += 1;
577 }
578 let value = &attributes[value_start..cursor];
579 cursor += 1;
580 if matches(name, value) {
581 return Some(value);
582 }
583 }
584 None
585}
586
587fn is_ap242_bo_model_xml(bytes: &[u8]) -> bool {
588 let bytes = &bytes[..bytes.len().min(4096)];
589 let Some((name, attributes)) = xml_root_start_tag(bytes) else {
590 return false;
591 };
592 let local_name = name
593 .iter()
594 .rposition(|byte| *byte == b':')
595 .map_or(name, |separator| &name[separator + 1..]);
596 local_name == b"Uos"
600 && BO_MODEL_NAMESPACES
601 .iter()
602 .any(|namespace| has_namespace_value(attributes, namespace))
603}
604
605const BO_MODEL_NAMESPACES: [&[u8]; 2] = [
606 b"http://standards.iso.org/iso/ts/10303/-3001/-ed-1/tech/xml-schema/bo_model",
607 b"http://standards.iso.org/iso/ts/10303/-3001/-ed-2/tech/xml-schema/bo_model",
608];
609
610#[cfg(test)]
611mod tests {
612 use std::io::Cursor;
613
614 use cadmpeg_core::decode::InspectOptions;
615 use cadmpeg_ir::codec::{Codec, CodecBackend, Confidence, DecodeOptions};
616
617 use super::{starts_with_step_magic, StepCodec};
618
619 #[test]
620 fn detects_magic_after_ignored_controls_and_inside_token() {
621 let source = b"\0 /* leading comment */ \\N\\ ISO-10303-\n21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
622 let codec = StepCodec::default();
623
624 assert!(starts_with_step_magic(source));
625 assert_eq!(codec.detect(source), Confidence::High);
626 codec
627 .decode(&mut Cursor::new(source), &DecodeOptions::default())
628 .expect("decode Part 21 with ignored framing octets");
629
630 let with_bom = [b"\xEF\xBB\xBF".as_slice(), source].concat();
631 assert_eq!(codec.detect(&with_bom), Confidence::No);
632 assert!(!starts_with_step_magic(b"/* incomplete ISO-10303-21;"));
633 }
634
635 #[test]
636 fn inspect_accepts_noncanonical_complex_partial_order_and_reports_a_note() {
637 let bytes = include_bytes!("../tests/fixtures/noncanonical_solid_angle.p21");
638 let summary = StepCodec::default()
639 .inspect(&mut Cursor::new(bytes), &InspectOptions::default())
640 .expect("inspection describes recoverable source order");
641
642 assert!(summary
643 .notes
644 .iter()
645 .any(|note| note.contains("complex partial records are not alphabetical")));
646 }
647}