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_GBXML_BYTES: u64 = 128 * 1024 * 1024;
16const MAX_GBXML_EVENTS: usize = 1_000_000;
17const MAX_GBXML_NODES: usize = 500_000;
18const MAX_GBXML_DEPTH: usize = 128;
19const MAX_GBXML_TEXT_BYTES: usize = 32 * 1024 * 1024;
20const MAX_GBXML_ROWS: usize = 200_000;
21const MAX_GBXML_DISPLAY_BYTES: usize = 512;
22
23#[derive(Default)]
24struct Summary {
25 version: String,
26 campuses: usize,
27 buildings: usize,
28 spaces: usize,
29 surfaces: usize,
30 openings: usize,
31 zones: usize,
32 constructions: usize,
33 materials: usize,
34 schedules: usize,
35 systems: usize,
36 locations: usize,
37 shell_geometries: usize,
38 occupants: usize,
39 rows: Vec<Vec<String>>,
40}
41
42struct GbxmlPageSink<'a> {
43 inner: &'a mut dyn PageConsumer,
44 warnings: &'a [String],
45}
46
47impl PageConsumer for GbxmlPageSink<'_> {
48 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
49 page.source_format = "gbxml".into();
50 if page.title.is_empty() {
51 page.title = "gbXML building model".into();
52 }
53 page.description =
54 "gbXML BIM and building-energy structure is rendered as bounded inert metadata; geometry, schedules and external resources are not evaluated".into();
55 for warning in self.warnings {
56 page.warn(warning.clone());
57 }
58 self.inner.consume(page)
59 }
60}
61
62pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
63 crate::geospatial::xml_tree::looks_like_root(prefix, b"gbXML", None)
64 && String::from_utf8_lossy(prefix)
65 .to_ascii_lowercase()
66 .contains("gbxml.org/schema")
67}
68
69pub(crate) fn convert(
70 path: &Path,
71 options: &ConvertOptions,
72 sink: &mut dyn PageConsumer,
73) -> Result<Vec<String>> {
74 let bytes = read_limited_file(
75 path,
76 options.max_input_bytes.min(MAX_GBXML_BYTES),
77 "gbXML input",
78 )?;
79 let root = parse_xml_tree(
80 &bytes,
81 &XmlLimits {
82 max_events: options.max_xml_events.min(MAX_GBXML_EVENTS),
83 max_nodes: MAX_GBXML_NODES,
84 max_depth: MAX_GBXML_DEPTH,
85 max_text_bytes: MAX_GBXML_TEXT_BYTES,
86 },
87 "gbXML",
88 )?;
89 if !root.name.eq_ignore_ascii_case("gbxml") {
90 return Err(Error::InvalidInput("gbXML root must be <gbXML>".into()));
91 }
92 if root
93 .namespace
94 .as_deref()
95 .is_none_or(|namespace| !namespace.to_ascii_lowercase().contains("gbxml.org/schema"))
96 {
97 return Err(Error::InvalidInput(
98 "gbXML root uses an unsupported namespace".into(),
99 ));
100 }
101 let mut summary = Summary {
102 version: attr_local(&root, "version")
103 .map(truncate)
104 .unwrap_or_default(),
105 campuses: count_named(&root, "Campus"),
106 buildings: count_named(&root, "Building"),
107 spaces: count_named(&root, "Space"),
108 surfaces: count_named(&root, "Surface"),
109 openings: count_named(&root, "Opening"),
110 zones: count_named(&root, "Zone"),
111 constructions: count_named(&root, "Construction"),
112 materials: count_named(&root, "Material"),
113 schedules: count_named(&root, "Schedule") + count_named(&root, "DaySchedule"),
114 systems: count_named(&root, "AirLoop")
115 + count_named(&root, "ZoneEquipments")
116 + count_named(&root, "BuildingStorey")
117 + count_named(&root, "System"),
118 locations: count_named(&root, "Location") + count_named(&root, "WeatherStation"),
119 shell_geometries: count_named(&root, "ShellGeometry")
120 + count_named(&root, "RectangularGeometry"),
121 occupants: count_named(&root, "People")
122 + count_named(&root, "Occupant")
123 + count_named(&root, "Occupants"),
124 ..Summary::default()
125 };
126 push_row(
127 &mut summary.rows,
128 "Model",
129 "gbXML",
130 &format!("version={}", display_or_dash(&summary.version)),
131 )?;
132 push_row(
133 &mut summary.rows,
134 "Building",
135 &summary.buildings.to_string(),
136 &format!(
137 "campuses={} spaces={} zones={}",
138 summary.campuses, summary.spaces, summary.zones
139 ),
140 )?;
141 push_row(
142 &mut summary.rows,
143 "Envelope",
144 &summary.surfaces.to_string(),
145 &format!(
146 "openings={} shellGeometry={}",
147 summary.openings, summary.shell_geometries
148 ),
149 )?;
150 push_row(
151 &mut summary.rows,
152 "Materials",
153 &summary.materials.to_string(),
154 &format!(
155 "constructions={} schedules={}",
156 summary.constructions, summary.schedules
157 ),
158 )?;
159 push_row(
160 &mut summary.rows,
161 "Systems",
162 &summary.systems.to_string(),
163 &format!(
164 "locations={} occupants={}",
165 summary.locations, summary.occupants
166 ),
167 )?;
168 let blocks = vec![
169 HtmlBlock::Heading {
170 level: 1,
171 text: "gbXML building model".into(),
172 },
173 HtmlBlock::Paragraph {
174 text: "Green Building XML structure is summarized for BIM and energy-analysis review; geometry, schedules, formulas and external resources remain inert.".into(),
175 },
176 HtmlBlock::Table(TableData {
177 headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
178 rows: summary.rows,
179 alignments: vec![TableAlign::Left; 3],
180 raw_source: String::new(),
181 }),
182 ];
183 let warnings = vec![
184 "gbXML IDs, coordinates, material properties, schedules, formulas, URLs and weather/resource payloads are omitted or redacted; only bounded structural counts are shown".into(),
185 "gbXML schema locations, external references, geometry calculations, energy simulation and CRS/unit conversion never run".into(),
186 ];
187 let mut page_sink = GbxmlPageSink {
188 inner: sink,
189 warnings: &warnings,
190 };
191 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
192 Ok(warnings)
193}
194
195fn count_named(element: &XmlElement, name: &str) -> usize {
196 element
197 .children
198 .iter()
199 .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
200 .sum()
201}
202
203fn attr_local<'a>(element: &'a XmlElement, name: &str) -> Option<&'a str> {
204 element.attributes.iter().find_map(|(key, value)| {
205 key.rsplit(':')
206 .next()
207 .filter(|local| local.eq_ignore_ascii_case(name))
208 .map(|_| value.as_str())
209 })
210}
211
212fn display_or_dash(value: &str) -> &str {
213 if value.is_empty() { "—" } else { value }
214}
215
216fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
217 if rows.len() >= MAX_GBXML_ROWS {
218 return Err(Error::LimitExceeded(format!(
219 "gbXML rows exceed {MAX_GBXML_ROWS}"
220 )));
221 }
222 rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
223 Ok(())
224}
225
226fn truncate(value: &str) -> String {
227 if value.len() <= MAX_GBXML_DISPLAY_BYTES {
228 value.to_owned()
229 } else {
230 let mut end = MAX_GBXML_DISPLAY_BYTES;
231 while end > 0 && !value.is_char_boundary(end) {
232 end -= 1;
233 }
234 format!("{}…", &value[..end])
235 }
236}