1use 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}