Skip to main content

cadmpeg_codec_step/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! STEP codec backend and encoder.
3
4use 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/// STEP encoder with per-export header options.
19#[derive(Debug, Clone, Default)]
20pub struct StepCodec {
21    /// Header metadata and deterministic writer options.
22    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        // `write_step` takes no fidelity sidecar, so the report it returns
35        // states the only resolution it can see. Whether the caller supplied
36        // one is known here, and only here.
37        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    /// Deep semantic analysis of a STEP exchange, exposed as container inspect.
68    ///
69    /// Runs the semantic decode path to populate `unknown_entities` and related
70    /// attributes. Not a cheap syntactic census; see
71    /// `docs/formats/step-inspect.md`.
72    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 &section.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    // CE-03/CE-04: Part 28 marker detection is not UOS conformance or schema
369    // mapping. The caller owns the exact binding, governing EXPRESS schema,
370    // derived XML Schema, identity/reference checks, and validation result;
371    // this codec has no Part 28 adapter and builds no partial graph.
372    // CE-05: HDF5 signature detection is not HDF5 validation or Part 26
373    // mapping. The caller owns the mapping edition, governing EXPRESS schema,
374    // HDF5 and Part 26 validation, resource-local row/reference mapping, and
375    // malformed-input result; this codec builds no partial graph.
376    // CE-06: Part 26 and Part 21 resource graphs have no universal join. The caller
377    // owns the exact resource identities, row-to-occurrence map, schema/unit/context
378    // agreement, conflict policy, and retention of both source graphs.
379    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    // A configured UOS can use a local name other than the document marker.
432    // Its governing-schema namespace varies by AP, but the Part 28 common
433    // namespace remains the bounded admission marker. Schema selection and
434    // the derived XML Schema remain caller inputs.
435    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    // BM-03: the published namespace must be bound on the Uos document
597    // element. Text, comments, schemaLocation values, and local names do not
598    // identify the alternate encoding.
599    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}