gldf_rs/lib.rs
1//! # gldf_rs
2//! GLDF (Global Lighting Data Format) Library
3//!
4//! **GitHub:** <https://github.com/holg/gldf-rs>
5//!
6//! The GLDF crate provides a set of structures and tools for working with the Global Lighting Data Format (GLDF),
7//! a standardized format for describing lighting products, their characteristics, and technical details.
8//!
9//! GLDF is used in the lighting industry to exchange product information between manufacturers, designers,
10//! and other stakeholders, ensuring consistent representation and interoperability across various software tools.
11//!
12//! This crate offers utilities for serializing and deserializing GLDF data, enabling you to read and write GLDF files
13//! while adhering to the ISO 7127 standard. It also provides helper macros for working with GLDF-specific attributes.
14//!
15//! For more information about GLDF and its specifications, <https::gldf.io> and refer to the ISO 7127 standard.
16//!
17//! # Features
18//!
19//! - Serialize and deserialize GLDF files in compliance with ISO 7127 standard.
20//! - From XML into JSON and vice versa.
21//! - Define GLDF-specific attributes using custom procedural macros.
22//! - Easily work with GLDF data structures and their components.
23//!
24//! For more usage examples and detailed documentation, please refer to the documentation of individual modules and structs.
25//! Most functions are implemented as methods on the struct GldfProduct, which shall represent the Root of the XML structure.
26//! **For more information see : gldf_rs::gldf::GldfProduct**
27//!
28//! [`GldfProduct`]
29//! # Example
30//! ```rust,no_run
31//! use gldf_rs::gldf::GldfProduct;
32//! let loaded: GldfProduct = GldfProduct::load_gldf("tests/data/test.gldf").unwrap();
33//! println!("{:?}", loaded);
34//! // Display pretty printed XML
35//! let x_serialized = loaded.to_xml().unwrap();
36//! println!("{}", x_serialized);
37//! let json_str = loaded.to_json().unwrap();
38//! println!("{}", json_str);
39//! let j_loaded: GldfProduct = GldfProduct::from_json(&json_str).unwrap();
40//! let x_reserialized = j_loaded.to_xml().unwrap();
41//! println!("{}", x_reserialized);
42//! assert_eq!(x_serialized, x_reserialized);
43//! ```
44//!
45//!
46//! For more information about GLDF and its specifications, refer to the ISO 7127 standard.
47//!
48//! # License
49//!
50//! This project is licensed under the terms of the MIT license.
51
52/// the gldf module (src/gldf/mod.rs)
53pub mod gldf;
54pub use gldf::*;
55
56/// Editable GLDF wrapper for editing and saving GLDF files
57pub mod editable;
58pub use editable::{EditableGldf, EditableGldfStats};
59
60/// Fix-up utilities for legacy / non-conforming GLDF archives.
61pub mod fix;
62pub use fix::fix_legacy_content_types;
63
64/// Validation engine for GLDF files
65pub mod validation;
66pub use validation::{ValidationError, ValidationLevel, ValidationResult};
67
68/// CRUD operations for GldfProduct
69pub mod operations;
70
71/// L3D to LDT mapping utilities
72pub mod mapping;
73pub use mapping::{
74 get_first_l3d_with_ldt, get_l3d_files_with_ldt, get_l3d_ldt_mappings, get_variant_emitter_data,
75 EmitterRenderData, L3dLdtMapping, L3dWithLdt, MountingInfo, MountingType, VariantEmitterData,
76};
77
78/// Conversion utilities for creating GLDF from other formats (LDT/IES)
79#[cfg(feature = "eulumdat")]
80pub mod convert;
81#[cfg(feature = "eulumdat")]
82pub use convert::{ldt_metadata_to_gldf, ldt_to_gldf, LdtMetadata};
83
84/// IFC (Industry Foundation Classes) integration for BIM interoperability
85pub mod ifc;
86pub use ifc::{
87 // IFC Import
88 ifc_to_gldf,
89 import_ifc,
90 GldfGenerator,
91 GldfToIfc,
92 IfcImporter,
93 ImportedLightSource,
94 ImportedLuminaire,
95 ImportedProperties,
96 ImportedVariant,
97 LightEmissionSourceEnum,
98 LightFixtureTypeEnum,
99 StepWriter,
100};
101
102/// Plugin system for embedded WASM viewer plugins
103pub mod plugin;
104pub use plugin::{Plugin, PluginManager, PluginManifest};
105
106#[cfg(test)]
107mod tests;
108
109use anyhow::{Context, Result};
110use regex::Regex;
111use serde_json::from_str as serde_from_str;
112use std::fs::File as StdFile;
113use std::io::Read;
114use std::path::Path;
115use std::path::PathBuf;
116use zip::ZipArchive;
117
118/// Target GLDF schema revision when serializing a `GldfProduct`.
119///
120/// rc.4 introduced backwards-compatible spelling fixes (the corrected
121/// `RatedChromaticityCoordinateValues` element name and `Fluorescent`
122/// enum values), keeping the old forms as deprecated aliases. The
123/// internal model always stores the rc.4-correct form; this enum picks
124/// which spelling lands in the output XML so producers can keep shipping
125/// rc.3-validating files until consumers catch up.
126#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
127pub enum GldfSchemaVersion {
128 /// Output the rc.3 spellings (`RatedChromacityCoordinateValues`,
129 /// `Flourescent Triphosphor`, `Flourescent Halophosphate`). Use this
130 /// for compatibility with consumers pinned to the pre-rc.4 schema.
131 Rc3,
132 /// Output the rc.4-corrected spellings. This is the default and
133 /// validates against both rc.4 and rc.3 (rc.4 keeps the old forms
134 /// as deprecated aliases, but the corrected forms are also accepted
135 /// by older consumers since the deprecation runs the other way).
136 #[default]
137 Rc4,
138}
139
140/// Rewrite rc.4-canonical spellings back to their rc.3 typo'd forms.
141///
142/// The rc.4 schema kept both spellings valid via `<xs:choice>` wrappers
143/// and deprecated-form enum aliases, so this rewrite is needed only for
144/// consumers that haven't bumped to rc.4 yet. Pure string replacement —
145/// the substrings we replace don't appear anywhere except as element
146/// names (`<RatedChromaticityCoordinateValues>` open/close tags) and as
147/// the literal enum values inside `<Cie97LampType>...</Cie97LampType>`
148/// element bodies, so global replace is safe.
149fn downconvert_rc4_to_rc3(xml: &str) -> String {
150 xml
151 // Element name (rc.4 only added this; rc.3 only knew the typo'd form)
152 .replace(
153 "RatedChromaticityCoordinateValues",
154 "RatedChromacityCoordinateValues",
155 )
156 // Cie97LampType enum values
157 .replace("Fluorescent Triphosphor", "Flourescent Triphosphor")
158 .replace("Fluorescent Halophosphate", "Flourescent Halophosphate")
159}
160
161impl GldfProduct {
162 pub fn detach(&mut self) -> anyhow::Result<()> {
163 Ok(())
164 }
165}
166
167#[derive(Clone, Debug, PartialEq)]
168pub struct BufFile {
169 pub name: Option<String>,
170 pub content: Option<Vec<u8>>,
171 pub file_id: Option<String>,
172 pub path: Option<String>,
173}
174
175pub struct FileBufGldf {
176 pub files: Vec<BufFile>,
177 pub gldf: GldfProduct,
178}
179
180/// Implementations for the per se informational GldfProduct struct
181impl GldfProduct {
182 /// Loads a GLDF file from a given path as String and return the XML String of the product.xml file
183 pub fn load_gldf_file_str(&self, path: String) -> anyhow::Result<String> {
184 let zipfile = StdFile::open(Path::new(&self.path))?;
185 let mut zip = ZipArchive::new(zipfile)?;
186 let mut some_str = String::new();
187 let mut some_file = zip.by_name(&path)?;
188 some_file.read_to_string(&mut some_str)?;
189 Ok(some_str)
190 }
191
192 /// Loads a GLDF file from a given path as `Vec<u8>` and return the `Vec<u8>` of the product.xml file
193 /// last but not least for WASM usage.
194 pub fn load_gldf_file(&self, path: String) -> anyhow::Result<Vec<u8>> {
195 let zipfile = StdFile::open(Path::new(&self.path))?;
196 let mut zip = ZipArchive::new(zipfile)?;
197 let mut file_buf = Vec::new();
198 let mut some_file = zip.by_name(&path)?;
199 some_file.read_to_end(&mut file_buf)?;
200 Ok(file_buf)
201 }
202
203 /// a helper function to used by the load_gldf function
204 /// takes a PathBuf and returns the XML String of the product.xml file
205 pub fn get_xml_str_from_gldf(path: PathBuf) -> anyhow::Result<String> {
206 let zipfile = StdFile::open(path)?;
207 let mut zip = ZipArchive::new(zipfile)?;
208 let mut xmlfile = zip.by_name("product.xml")?;
209 let mut xml_str = String::new();
210 xmlfile.read_to_string(&mut xml_str)?;
211 Ok(xml_str)
212 }
213
214 /// a helper function to remove the UTF8 Bom, if present from a given String
215 /// takes a String and returns a String
216 /// needed for some GLDF files, which have BOM in the XML file
217 pub fn remove_bom(s: &str) -> String {
218 if let Some(stripped) = s.strip_prefix("\u{FEFF}") {
219 stripped.to_string()
220 } else {
221 s.to_string()
222 }
223 }
224
225 /// a helper function to sanitize the XML String
226 /// takes a String and returns a String
227 /// GldfProduct does not really care about the XSD version, so we remove it
228 /// and add our own later
229 pub fn sanitize_xml_str(xml_str: &str) -> String {
230 let cleaned_str = Self::remove_bom(xml_str);
231 let re = Regex::new(r"<Root .*?>").unwrap();
232 // well we are lazy for now and simple replace the root element with a generic one
233 let cleaned = re.replace_all(&cleaned_str, "<Root>").to_string();
234 // Upconvert rc.3 typo'd forms to the rc.4 corrected spellings
235 // before the serde-XML pass sees them. The element name is
236 // already covered by `serde(alias = ...)` on the field, so this
237 // step only matters for the enum string values inside
238 // `<Cie97LampType>...</Cie97LampType>` element bodies — those
239 // arrive as plain `String`, no alias machinery applies.
240 cleaned
241 .replace("Flourescent Triphosphor", "Fluorescent Triphosphor")
242 .replace("Flourescent Halophosphate", "Fluorescent Halophosphate")
243 }
244
245 /// a helper function to load a XML String and return the GldfProduct struct
246 pub fn from_xml(xml_str: &str) -> anyhow::Result<GldfProduct> {
247 let my_xml_str = Self::sanitize_xml_str(xml_str);
248 let result: GldfProduct = quick_xml::de::from_str(&my_xml_str)
249 .map_err(|e| anyhow::anyhow!("XML parsing error: {}", e))?;
250 Ok(result)
251 }
252
253 /// Argument the &str path to the GLDF file and return the GldfProduct struct
254 pub fn load_gldf(path: &str) -> anyhow::Result<GldfProduct> {
255 let path_buf = Path::new(path).to_path_buf();
256 let xml_str = GldfProduct::get_xml_str_from_gldf(path_buf)
257 .map_err(anyhow::Error::msg)
258 .context("Failed to parse XML string")?;
259 let mut loaded: GldfProduct = GldfProduct::from_xml(&xml_str)?;
260 loaded.path = path.to_string();
261 Ok(loaded)
262 }
263
264 /// A helper for the WASM, which has the GLDF file as `Vec<u8>` and returns all the files as `Vec<BufFile>`
265 /// which can be later rendered into HTML, e.g. for some GLDF Viewer
266 pub fn load_gldf_from_buf_all(gldf_buf: Vec<u8>) -> anyhow::Result<FileBufGldf> {
267 let zip_buf = std::io::Cursor::new(gldf_buf);
268 let mut zip = ZipArchive::new(zip_buf).context("Failed to open GLDF as ZIP archive")?;
269 let mut file_bufs: Vec<BufFile> = Vec::new();
270 let mut xmlfile = zip
271 .by_name("product.xml")
272 .context("product.xml not found in GLDF archive")?;
273 let mut xml_str = String::new();
274 xmlfile
275 .read_to_string(&mut xml_str)
276 .context("Failed to read product.xml")?;
277 let loaded: GldfProduct =
278 GldfProduct::from_xml(&xml_str).context("Failed to parse product.xml")?;
279 drop(xmlfile);
280
281 for i in 0..zip.len() {
282 if let Ok(mut file) = zip.by_index(i) {
283 if file.is_file() {
284 let mut buf: Vec<u8> = Vec::new();
285 if file.read_to_end(&mut buf).is_ok() {
286 let buf_file = BufFile {
287 name: Some(file.name().to_string()),
288 content: Some(buf),
289 file_id: None,
290 path: Some(file.name().to_string()),
291 };
292 file_bufs.push(buf_file);
293 }
294 }
295 }
296 }
297 let file_buf = FileBufGldf {
298 files: file_bufs,
299 gldf: loaded,
300 };
301
302 Ok(file_buf)
303 }
304
305 /// A helper function. Argument is the `Vec<u8>` of the GLDF file and returns the GldfProduct struct
306 /// WASM usage e.g.
307 pub fn load_gldf_from_buf(file_buf: Vec<u8>) -> anyhow::Result<GldfProduct> {
308 let zip_buf = std::io::Cursor::new(file_buf);
309 let mut zip = ZipArchive::new(zip_buf).context("Failed to open GLDF as ZIP archive")?;
310 let mut xmlfile = zip
311 .by_name("product.xml")
312 .context("product.xml not found in GLDF archive")?;
313 let mut xml_str = String::new();
314 xmlfile
315 .read_to_string(&mut xml_str)
316 .context("Failed to read product.xml")?;
317 let loaded: GldfProduct =
318 GldfProduct::from_xml(&xml_str).context("Failed to parse product.xml")?;
319 Ok(loaded)
320 }
321
322 /// represent the GldfProduct as JSON String
323 pub fn to_json(&self) -> anyhow::Result<String> {
324 let json_str = serde_json::to_string(&self)?;
325 Ok(json_str)
326 }
327
328 /// represent the GldfProduct as pretty pretty JSON String
329 pub fn to_pretty_json(&self) -> anyhow::Result<String> {
330 let json_str =
331 serde_json::to_string_pretty(&self).context("Failed to serialize to JSON")?;
332 Ok(json_str)
333 }
334
335 /// loads a given JSON &str and returns the GldfProduct struct
336 pub fn from_json(json_str: &str) -> anyhow::Result<GldfProduct> {
337 let j_loaded: GldfProduct = serde_from_str(json_str)?;
338 Ok(j_loaded)
339 }
340
341 /// loads a given JSON file from a PathBuf and returns the GldfProduct struct
342 /// last but not least for WASM usage.
343 pub fn from_json_file(path: PathBuf) -> anyhow::Result<GldfProduct> {
344 let mut json_file = StdFile::open(&path)
345 .with_context(|| format!("Failed to open JSON file: {:?}", path))?;
346 let mut json_str = String::new();
347 json_file
348 .read_to_string(&mut json_str)
349 .context("Failed to read JSON file")?;
350 GldfProduct::from_json(&json_str).context("Failed to parse JSON content")
351 }
352
353 /// Represent the GldfProduct as an XML string targeting the rc.4
354 /// schema (the default). Internal model always stores the rc.4
355 /// canonical form (`RatedChromaticityCoordinateValues`,
356 /// `Fluorescent Triphosphor`, etc.), so the rc.4 path is a straight
357 /// serialize. For rc.3 output use [`Self::to_xml_with_schema`].
358 pub fn to_xml(&self) -> anyhow::Result<String> {
359 self.to_xml_with_schema(GldfSchemaVersion::Rc4)
360 }
361
362 /// Serialize as XML targeting a specific GLDF schema revision.
363 ///
364 /// **rc.4** (default) writes the corrected element names and enum
365 /// values (`RatedChromaticityCoordinateValues`,
366 /// `Fluorescent Triphosphor`, `Fluorescent Halophosphate`).
367 ///
368 /// **rc.3** rewrites those back to the historical typo'd forms
369 /// (`RatedChromacityCoordinateValues`, `Flourescent Triphosphor`,
370 /// `Flourescent Halophosphate`) so files validate against the
371 /// pre-rc.4 XSD that some downstream tools still use. The conversion
372 /// is a pure string-replace pass on the serialized XML — no
373 /// duplicate types or feature flags involved.
374 pub fn to_xml_with_schema(&self, version: GldfSchemaVersion) -> anyhow::Result<String> {
375 let xml_str = quick_xml::se::to_string(&self)?;
376 let xml_str = match version {
377 GldfSchemaVersion::Rc4 => xml_str,
378 GldfSchemaVersion::Rc3 => downconvert_rc4_to_rc3(&xml_str),
379 };
380 Ok(format!(
381 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
382 xml_str
383 ))
384 }
385
386 /// returns the photometric files as Vec<&File>
387 pub fn get_phot_files(&self) -> anyhow::Result<Vec<&File>> {
388 let mut result: Vec<&File> = Vec::new();
389 for f in self.general_definitions.files.file.iter() {
390 let content_type = &f.content_type;
391 if content_type.starts_with("ldc") {
392 result.push(f)
393 }
394 }
395 Ok(result.to_owned())
396 }
397
398 /// returns the image files as Vec<&File>
399 pub fn get_image_def_files(&self) -> Result<Vec<&File>> {
400 let mut result: Vec<&File> = Vec::new();
401 for f in self.general_definitions.files.file.iter() {
402 let content_type = &f.content_type;
403 if content_type.starts_with("image") {
404 result.push(f)
405 }
406 }
407 Ok(result.to_owned())
408 }
409
410 /// returns the image files as Vec<&File>
411 pub fn get_image_zip_files(&self) -> anyhow::Result<Vec<&File>> {
412 let mut result: Vec<&File> = Vec::new();
413 for f in self.general_definitions.files.file.iter() {
414 let content_type = &f.content_type;
415 if content_type.starts_with("image") {
416 result.push(f)
417 }
418 }
419 Ok(result.to_owned())
420 }
421
422 /// from the given file_id of the ldc file reference, return the ldc file as String
423 /// it could as well be the type_attr "url", which will be fetched from the web first
424 /// overridden for the WASM portage, so not used for WASM portage
425 #[cfg(feature = "http")]
426 pub async fn get_ldc_by_id(&self, file_id: String) -> Result<String, anyhow::Error> {
427 let mut result: String = "".to_owned();
428 for f in self.general_definitions.files.file.iter() {
429 if f.id == file_id {
430 let mut ldc_path = "ldc/".to_owned();
431 let file_name = f.file_name.to_owned();
432 if f.type_attr == "url" {
433 let tmp = fetch_text_from_url(&file_name).await?;
434 result.push_str(tmp.as_str());
435 } else {
436 ldc_path.push_str(&file_name);
437 result.push_str(&self.load_gldf_file_str(ldc_path)?);
438 }
439 }
440 }
441 Ok(result)
442 }
443
444 /// from the given file_id of the ldc file reference, return the ldc file as String
445 /// (non-HTTP version - does not support URL type files)
446 #[cfg(not(feature = "http"))]
447 pub fn get_ldc_by_id(&self, file_id: String) -> Result<String, anyhow::Error> {
448 let mut result: String = "".to_owned();
449 for f in self.general_definitions.files.file.iter() {
450 if f.id == file_id {
451 if f.type_attr == "url" {
452 return Err(anyhow::anyhow!(
453 "URL file type not supported without http feature"
454 ));
455 }
456 let mut ldc_path = "ldc/".to_owned();
457 ldc_path.push_str(&f.file_name);
458 result.push_str(&self.load_gldf_file_str(ldc_path)?);
459 }
460 }
461 Ok(result)
462 }
463
464 /// Gets all the file definitions as `Vec<File>`
465 pub fn get_all_file_definitions(&self) -> anyhow::Result<Vec<File>> {
466 let mut result: Vec<File> = Vec::new();
467 for f in self.general_definitions.files.file.iter() {
468 result.push(f.to_owned());
469 }
470 Ok(result)
471 }
472
473 /// Gets all the file definitions which are of content_type url as `Vec<File>`
474 pub fn get_url_file_definitions(&self) -> anyhow::Result<Vec<File>> {
475 let mut result: Vec<File> = Vec::new();
476 for f in self.general_definitions.files.file.iter() {
477 if f.content_type == "url" {
478 result.push(f.to_owned());
479 }
480 }
481 Ok(result)
482 }
483}
484
485/// helper function to get the content of the url as File from the given url
486#[cfg(feature = "http")]
487pub async fn fetch_text_from_url(url: &str) -> Result<String, reqwest::Error> {
488 let response = reqwest::get(url).await?;
489 let text = response.text().await?;
490 Ok(text)
491}
492
493#[cfg(feature = "http")]
494pub async fn fetch_content_from_url(url: &str) -> Result<Vec<u8>, reqwest::Error> {
495 let response = reqwest::get(url).await?;
496 let content = response.bytes().await?;
497 Ok(content.to_vec())
498}