Skip to main content

document_svg/document/
landxml.rs

1//! Bounded LandXML 1.2 civil-engineering exchange previews.
2//!
3//! LandXML carries survey points, TIN surfaces, parcels, road alignments and
4//! pipe networks. This adapter summarizes those structures without executing
5//! survey references, external schemas or design/measurement calculations.
6
7use 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}