Skip to main content

document_svg/document/
threedxml.rs

1//! Bounded Dassault Systèmes 3DXML package previews.
2//!
3//! A 3DXML exchange file is a ZIP container with a manifest, a product
4//! structure XML document, and one or more 3DRep representations.  This
5//! adapter follows only the local manifest/root XML relationship and renders
6//! structural counts.  Binary tessellation, exact NURBS geometry, textures,
7//! external files, and viewer automation remain inert.
8
9use std::fs;
10use std::path::Path;
11
12use crate::convert::{ConvertOptions, PageConsumer};
13use crate::document::html::{HtmlBlock, render_blocks_to_pages};
14use crate::error::{Error, Result};
15use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
16use crate::ooxml::ZipPackage;
17use crate::table::{TableAlign, TableData};
18
19const MAX_3DXML_BYTES: u64 = 512 * 1024 * 1024;
20const MAX_3DXML_XML_BYTES: u64 = 32 * 1024 * 1024;
21const MAX_3DXML_EVENTS: usize = 1_000_000;
22const MAX_3DXML_NODES: usize = 500_000;
23const MAX_3DXML_DEPTH: usize = 128;
24const MAX_3DXML_ROWS: usize = 200_000;
25const MAX_3DXML_TARGET_BYTES: usize = 1024;
26
27#[derive(Default)]
28struct Summary {
29    manifest: bool,
30    root_target: String,
31    product_structure: usize,
32    reference_3d: usize,
33    instance_3d: usize,
34    reference_rep: usize,
35    instance_rep: usize,
36    reps: usize,
37    geometry_nodes: usize,
38    metadata_nodes: usize,
39}
40
41struct ThreeDXmlPageSink<'a> {
42    inner: &'a mut dyn PageConsumer,
43    warnings: &'a [String],
44}
45
46impl PageConsumer for ThreeDXmlPageSink<'_> {
47    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
48        page.source_format = "3dxml".into();
49        if page.title.is_empty() {
50            page.title = "Dassault 3DXML package".into();
51        }
52        page.description = "3DXML manifest and product-structure metadata are rendered as bounded inert rows; binary representations are not decoded".into();
53        for warning in self.warnings {
54            page.warn(warning.clone());
55        }
56        self.inner.consume(page)
57    }
58}
59
60pub(crate) fn looks_like_archive(path: &Path) -> bool {
61    if fs::metadata(path)
62        .ok()
63        .is_none_or(|metadata| metadata.len() > MAX_3DXML_BYTES)
64    {
65        return false;
66    }
67    let Ok(mut package) = ZipPackage::open(path, MAX_3DXML_XML_BYTES) else {
68        return false;
69    };
70    ["Manifest.xml", "_3DXML/Manifest.xml", "manifest.xml"]
71        .into_iter()
72        .any(|name| {
73            package.contains(name)
74                && package
75                    .read_optional_limited(name, 64 * 1024)
76                    .ok()
77                    .flatten()
78                    .is_some_and(|bytes| looks_like_manifest(&bytes))
79        })
80}
81
82pub(crate) fn convert(
83    path: &Path,
84    options: &ConvertOptions,
85    sink: &mut dyn PageConsumer,
86) -> Result<Vec<String>> {
87    let metadata = fs::metadata(path)?;
88    let max_bytes = options.max_input_bytes.min(MAX_3DXML_BYTES);
89    if metadata.len() > max_bytes {
90        return Err(Error::LimitExceeded(format!(
91            "3DXML input exceeds maximum bytes ({max_bytes})"
92        )));
93    }
94    let mut package = ZipPackage::open(path, options.max_zip_entry_bytes)?;
95    let manifest_bytes = read_first_present(
96        &mut package,
97        &["Manifest.xml", "_3DXML/Manifest.xml", "manifest.xml"],
98        MAX_3DXML_XML_BYTES,
99    )?
100    .ok_or_else(|| Error::InvalidInput("3DXML package has no Manifest.xml".into()))?;
101    let manifest = parse_xml(&manifest_bytes, "3DXML manifest")?;
102    let root_target = find_named_text(&manifest, "Root")
103        .and_then(|value| safe_target(value.trim()))
104        .ok_or_else(|| Error::InvalidInput("3DXML manifest has no safe Root target".into()))?;
105    let root_bytes = package
106        .read_limited(&root_target, MAX_3DXML_XML_BYTES)
107        .or_else(|_| {
108            let basename = root_target.rsplit('/').next().unwrap_or(&root_target);
109            package.read_limited(basename, MAX_3DXML_XML_BYTES)
110        })?;
111    let root = parse_xml(&root_bytes, "3DXML product structure")?;
112    let summary = Summary {
113        manifest: true,
114        root_target,
115        product_structure: count_named(&root, "ProductStructure"),
116        reference_3d: count_named(&root, "Reference3D"),
117        instance_3d: count_named(&root, "Instance3D"),
118        reference_rep: count_named(&root, "ReferenceRep"),
119        instance_rep: count_named(&root, "InstanceRep"),
120        reps: count_named(&root, "Rep")
121            + count_named(&root, "Representation")
122            + count_named(&root, "3DRep"),
123        geometry_nodes: count_named(&root, "Polygon")
124            + count_named(&root, "Triangle")
125            + count_named(&root, "VertexBuffer"),
126        metadata_nodes: count_named(&root, "PLMEntity") + count_named(&root, "UserRefProperties"),
127    };
128    let rows = vec![
129        vec![
130            "Manifest".into(),
131            if summary.manifest {
132                "present"
133            } else {
134                "missing"
135            }
136            .into(),
137        ],
138        vec!["Root target".into(), summary.root_target.clone()],
139        vec![
140            "ProductStructure".into(),
141            summary.product_structure.to_string(),
142        ],
143        vec!["Reference3D".into(), summary.reference_3d.to_string()],
144        vec!["Instance3D".into(), summary.instance_3d.to_string()],
145        vec!["ReferenceRep".into(), summary.reference_rep.to_string()],
146        vec!["InstanceRep".into(), summary.instance_rep.to_string()],
147        vec!["Representations".into(), summary.reps.to_string()],
148        vec![
149            "Geometry markers".into(),
150            summary.geometry_nodes.to_string(),
151        ],
152        vec![
153            "Metadata markers".into(),
154            summary.metadata_nodes.to_string(),
155        ],
156        vec!["ZIP entries".into(), package.entry_count().to_string()],
157    ];
158    if rows.len() > MAX_3DXML_ROWS {
159        return Err(Error::LimitExceeded(format!(
160            "3DXML rows exceed {MAX_3DXML_ROWS}"
161        )));
162    }
163    let blocks = vec![
164        HtmlBlock::Heading {
165            level: 1,
166            text: "Dassault 3DXML package".into(),
167        },
168        HtmlBlock::Paragraph {
169            text: "The local Manifest.xml → Root relationship is validated and the product structure is summarized without decoding binary 3DRep payloads or opening external resources.".into(),
170        },
171        HtmlBlock::Table(TableData {
172            headers: vec!["Metric".into(), "Value".into()],
173            rows,
174            alignments: vec![TableAlign::Left, TableAlign::Right],
175            raw_source: String::new(),
176        }),
177    ];
178    let warnings = vec![
179        "3DXML tessellation, NURBS/exact geometry, transforms, materials, textures, annotations, and binary 3DRep data are not decoded".into(),
180        "External files, URLs, scripts, plug-in automation, and viewer operations remain inert and are never opened".into(),
181    ];
182    let mut page_sink = ThreeDXmlPageSink {
183        inner: sink,
184        warnings: &warnings,
185    };
186    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
187    Ok(warnings)
188}
189
190fn read_first_present(
191    package: &mut ZipPackage<std::fs::File>,
192    names: &[&str],
193    max_bytes: u64,
194) -> Result<Option<Vec<u8>>> {
195    for name in names {
196        if package.contains(name) {
197            return package.read_limited(name, max_bytes).map(Some);
198        }
199    }
200    Ok(None)
201}
202
203fn parse_xml(bytes: &[u8], context: &str) -> Result<XmlElement> {
204    parse_xml_tree(
205        bytes,
206        &XmlLimits {
207            max_events: MAX_3DXML_EVENTS,
208            max_nodes: MAX_3DXML_NODES,
209            max_depth: MAX_3DXML_DEPTH,
210            max_text_bytes: MAX_3DXML_XML_BYTES as usize,
211        },
212        context,
213    )
214}
215
216fn find_named_text<'a>(element: &'a XmlElement, name: &str) -> Option<&'a str> {
217    if element.name.eq_ignore_ascii_case(name) && !element.text.trim().is_empty() {
218        return Some(element.text.trim());
219    }
220    element
221        .children
222        .iter()
223        .find_map(|child| find_named_text(child, name))
224}
225
226fn safe_target(value: &str) -> Option<String> {
227    let target = value
228        .strip_prefix("urn:3DXML:")
229        .or_else(|| value.strip_prefix("urn:3dxml:"))
230        .unwrap_or(value)
231        .replace('\\', "/");
232    if target.is_empty()
233        || target.len() > MAX_3DXML_TARGET_BYTES
234        || target.contains('\0')
235        || target
236            .split('/')
237            .any(|part| part == ".." || part.is_empty())
238    {
239        return None;
240    }
241    Some(target.trim_start_matches('/').to_owned())
242}
243
244fn looks_like_manifest(bytes: &[u8]) -> bool {
245    let text = String::from_utf8_lossy(bytes);
246    text.contains("3DXML")
247        && (text.contains("<Manifest") || text.contains(":Manifest"))
248        && (text.contains("<Root") || text.contains(":Root"))
249}
250
251fn count_named(element: &XmlElement, name: &str) -> usize {
252    usize::from(element.name.eq_ignore_ascii_case(name))
253        + element
254            .children
255            .iter()
256            .map(|child| count_named(child, name))
257            .sum::<usize>()
258}