1use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
13use crate::table::{TableAlign, TableData};
14
15const MAX_LANDXML_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_LANDXML_XML_EVENTS: usize = 1_000_000;
17const MAX_LANDXML_XML_NODES: usize = 500_000;
18const MAX_LANDXML_XML_DEPTH: usize = 128;
19const MAX_LANDXML_TEXT_BYTES: usize = 48 * 1024 * 1024;
20const MAX_LANDXML_ROWS: usize = 200_000;
21const MAX_LANDXML_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
24 let root = crate::geospatial::xml_tree::looks_like_root(bytes, b"LandXML", None)
25 || crate::geospatial::xml_tree::looks_like_root(bytes, b"LandXml", None);
26 if !root {
27 return false;
28 }
29 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
30 text.contains("landxml.org/schema/landxml")
31 && (text.contains("<surfaces")
32 || text.contains("<alignments")
33 || text.contains("<cgpoints")
34 || text.contains("<pipenetworks")
35 || text.contains("<parcels"))
36}
37
38struct LandXmlPageSink<'a> {
39 inner: &'a mut dyn PageConsumer,
40 warnings: &'a [String],
41}
42impl PageConsumer for LandXmlPageSink<'_> {
43 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
44 page.source_format = "landxml".into();
45 if page.title.is_empty() {
46 page.title = "LandXML civil model".into();
47 }
48 page.description = "LandXML civil-engineering structures are rendered as a bounded inert summary; external references and survey calculations are not resolved".into();
49 for warning in self.warnings {
50 page.warn(warning.clone());
51 }
52 self.inner.consume(page)
53 }
54}
55
56#[derive(Default)]
57struct Summary {
58 version: String,
59 date: String,
60 units: String,
61 surfaces: usize,
62 surface_points: usize,
63 surface_faces: usize,
64 alignments: usize,
65 alignment_segments: usize,
66 parcels: usize,
67 cpoints: usize,
68 pipe_networks: usize,
69 pipes: usize,
70 structures: usize,
71 rows: Vec<Vec<String>>,
72}
73
74pub(crate) fn convert(
75 path: &Path,
76 options: &ConvertOptions,
77 sink: &mut dyn PageConsumer,
78) -> Result<Vec<String>> {
79 let bytes = read_limited_file(
80 path,
81 options.max_input_bytes.min(MAX_LANDXML_BYTES),
82 "LandXML input",
83 )?;
84 let root = parse_xml_tree(
85 &bytes,
86 &XmlLimits {
87 max_events: options.max_xml_events.min(MAX_LANDXML_XML_EVENTS),
88 max_nodes: MAX_LANDXML_XML_NODES,
89 max_depth: MAX_LANDXML_XML_DEPTH,
90 max_text_bytes: MAX_LANDXML_TEXT_BYTES,
91 },
92 "LandXML",
93 )?;
94 if !root.name.eq_ignore_ascii_case("LandXML") && !root.name.eq_ignore_ascii_case("LandXml") {
95 return Err(Error::InvalidInput("LandXML root must LandXML".into()));
96 }
97 if !root
98 .namespace
99 .as_deref()
100 .is_some_and(|ns| ns.contains("landxml.org/schema/LandXML"))
101 {
102 return Err(Error::InvalidInput(
103 "LandXML namespace is missing or unsupported".into(),
104 ));
105 }
106 let mut summary = Summary {
107 version: root.attribute("version").unwrap_or_default().to_owned(),
108 date: root.attribute("date").unwrap_or_default().to_owned(),
109 units: first_attr_descendant(&root, "Imperial")
110 .or_else(|| first_attr_descendant(&root, "Metric"))
111 .unwrap_or_else(|| "unspecified".into()),
112 surfaces: count_named(&root, "Surface"),
113 alignments: count_named(&root, "Alignment"),
114 parcels: count_named(&root, "Parcel"),
115 cpoints: count_named(&root, "CgPoint"),
116 pipe_networks: count_named(&root, "PipeNetwork"),
117 pipes: count_named(&root, "Pipe"),
118 structures: count_named(&root, "Struct"),
119 ..Summary::default()
120 };
121 for surface in descendants_named(&root, "Surface") {
122 let name = surface.attribute("name").unwrap_or("[unnamed surface]");
123 let points = count_named(surface, "P").saturating_add(count_named(surface, "Pnt"));
124 let faces = count_named(surface, "F").saturating_add(count_named(surface, "PFace"));
125 summary.surface_points = summary.surface_points.saturating_add(points);
126 summary.surface_faces = summary.surface_faces.saturating_add(faces);
127 push_row(
128 &mut summary.rows,
129 "Surface",
130 name,
131 &format!("points={points} faces={faces}"),
132 )?;
133 }
134 summary.alignment_segments = descendants_named(&root, "Alignments")
135 .first()
136 .map(|alignments| {
137 count_named(alignments, "Line")
138 + count_named(alignments, "Curve")
139 + count_named(alignments, "Spiral")
140 })
141 .unwrap_or(0);
142 if summary.surfaces == 0
143 && summary.alignments == 0
144 && summary.cpoints == 0
145 && summary.parcels == 0
146 && summary.pipe_networks == 0
147 {
148 return Err(Error::InvalidInput(
149 "LandXML contains no supported civil structures".into(),
150 ));
151 }
152 push_row(
153 &mut summary.rows,
154 "Document",
155 "LandXML",
156 &format!(
157 "version={} date={}",
158 display_or_dash(&summary.version),
159 display_or_dash(&summary.date)
160 ),
161 )?;
162 push_row(&mut summary.rows, "Units", &summary.units, "root units")?;
163 push_row(
164 &mut summary.rows,
165 "Surfaces",
166 &summary.surfaces.to_string(),
167 &format!(
168 "points={} faces={}",
169 summary.surface_points, summary.surface_faces
170 ),
171 )?;
172 push_row(
173 &mut summary.rows,
174 "Alignments",
175 &summary.alignments.to_string(),
176 &format!("segments={}", summary.alignment_segments),
177 )?;
178 push_row(
179 &mut summary.rows,
180 "Survey points",
181 &summary.cpoints.to_string(),
182 "CgPoint coordinates omitted",
183 )?;
184 push_row(
185 &mut summary.rows,
186 "Parcels",
187 &summary.parcels.to_string(),
188 "boundary geometry omitted",
189 )?;
190 push_row(
191 &mut summary.rows,
192 "Pipe networks",
193 &summary.pipe_networks.to_string(),
194 &format!("pipes={} structures={}", summary.pipes, summary.structures),
195 )?;
196 let metadata = format!(
197 "Version: {}\nDate: {}\nUnits: {}\nSurfaces: {} ({} points / {} faces)\nAlignments: {}\nSurvey points: {}\nParcels: {}\nPipe networks: {}",
198 display_or_dash(&summary.version),
199 display_or_dash(&summary.date),
200 display_or_dash(&summary.units),
201 summary.surfaces,
202 summary.surface_points,
203 summary.surface_faces,
204 summary.alignments,
205 summary.cpoints,
206 summary.parcels,
207 summary.pipe_networks
208 );
209 let blocks = vec![
210 HtmlBlock::Heading {
211 level: 1,
212 text: "LandXML civil model".into(),
213 },
214 HtmlBlock::Paragraph { text: metadata },
215 HtmlBlock::Table(TableData {
216 headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
217 rows: summary.rows,
218 alignments: vec![TableAlign::Left; 3],
219 raw_source: String::new(),
220 }),
221 ];
222 let warnings = vec![
223 "LandXML version/date/units and surface, alignment, survey-point, parcel and pipe-network counts are shown; coordinate payloads, boundary geometry, design profiles, external schema URLs and survey metadata are omitted".into(),
224 "LandXML XML traversal and rows are bounded; DTD/entities, XInclude, external schema fetches, file references, terrain calculations, stationing and CAD/field-device operations never run".into(),
225 ];
226 let mut page_sink = LandXmlPageSink {
227 inner: sink,
228 warnings: &warnings,
229 };
230 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
231 Ok(warnings)
232}
233
234fn count_named(element: &XmlElement, name: &str) -> usize {
235 element
236 .children
237 .iter()
238 .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
239 .sum()
240}
241fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
242 let mut result = Vec::new();
243 for child in &element.children {
244 if child.name.eq_ignore_ascii_case(name) {
245 result.push(child);
246 }
247 result.extend(descendants_named(child, name));
248 }
249 result
250}
251fn first_attr_descendant(element: &XmlElement, name: &str) -> Option<String> {
252 let node = descendants_named(element, name).first().copied()?;
253 ["linearUnit", "areaUnit", "volumeUnit"]
254 .iter()
255 .find_map(|key| node.attribute(key))
256 .map(truncate)
257}
258fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
259 if rows.len() >= MAX_LANDXML_ROWS {
260 return Err(Error::LimitExceeded(format!(
261 "LandXML rows exceed {MAX_LANDXML_ROWS}"
262 )));
263 }
264 rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
265 Ok(())
266}
267fn display_or_dash(value: &str) -> String {
268 if value.is_empty() {
269 "-".into()
270 } else {
271 truncate(value)
272 }
273}
274fn truncate(value: &str) -> String {
275 if value.len() <= MAX_LANDXML_DISPLAY_BYTES {
276 return value.to_owned();
277 }
278 let mut end = MAX_LANDXML_DISPLAY_BYTES;
279 while !value.is_char_boundary(end) {
280 end -= 1;
281 }
282 format!("{}…", &value[..end])
283}
284
285#[cfg(test)]
286mod tests {
287 use super::looks_like_prefix;
288 #[test]
289 fn recognizes_landxml_namespace() {
290 assert!(looks_like_prefix(
291 br#"<LandXML xmlns="http://www.landxml.org/schema/LandXML-1.2"><Surfaces/></LandXML>"#
292 ));
293 }
294 #[test]
295 fn rejects_generic_xml() {
296 assert!(!looks_like_prefix(br#"<LandXML><Surfaces/></LandXML>"#));
297 }
298}