use std::{collections::BTreeMap, io::Read, path::Path};
mod athena;
mod beamlines;
mod binary;
mod hdf5;
mod larix;
mod model;
mod selection;
mod signals;
mod text;
mod xtunes;
pub use model::{
EnergyConversion, Measurement, MeasurementColumn, MeasurementDataset, MeasurementScan,
ReadError, SignalCandidate, SignalConversion, SpectrumMapping,
};
pub use selection::{ColumnSelector, SignalSelection, SpectrumSelection};
pub fn read_measurement(path: impl AsRef<Path>) -> Result<Measurement, ReadError> {
let path = path.as_ref();
let file = std::fs::File::open(path)
.map_err(|e| ReadError::new(format!("{}: {e}", path.display())))?;
let mut bytes = Vec::new();
file.take(MAX_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| ReadError::new(e.to_string()))?;
parse_measurement(&bytes).map_err(|e| ReadError::new(format!("{}: {e}", path.display())))
}
const MAX_BYTES: usize = 256 * 1024 * 1024;
pub fn parse_measurement(bytes: &[u8]) -> Result<Measurement, ReadError> {
if bytes.len() > MAX_BYTES {
return Err(ReadError::new(
"Measurement exceeds the 256 MiB input limit",
));
}
if bytes.starts_with(&[0x1f, 0x8b]) {
let mut decoded = Vec::new();
let mut decoder = flate2::bufread::GzDecoder::new(bytes);
decoder
.by_ref()
.take(MAX_BYTES as u64 + 1)
.read_to_end(&mut decoded)
.map_err(|e| ReadError::new(format!("Invalid gzip container: {e}")))?;
if decoded.len() > MAX_BYTES {
return Err(ReadError::new(
"Measurement exceeds the 256 MiB gzip expansion limit",
));
}
if !decoder.into_inner().is_empty() {
return Err(ReadError::new(
"Invalid gzip container: trailing data or additional gzip members; supply one measurement per container",
));
}
if decoded.starts_with(&[0x1f, 0x8b]) {
return Err(ReadError::new("Nested gzip containers are unsupported"));
}
let mut result = parse_measurement(&decoded)?;
result.warnings.push("Source was gzip-compressed.".into());
return Ok(result);
}
if hdf5_pure::is_hdf5_bytes(bytes) {
return hdf5::parse(bytes);
}
if let Some(result) = binary::parse(bytes) {
return result;
}
let (text, warning) = match std::str::from_utf8(bytes) {
Ok(text) => (text.to_owned(), None),
Err(_) => {
let (decoded, _, errors) = encoding_rs::SHIFT_JIS.decode(bytes);
if errors {
return Err(ReadError::new(
"Unknown binary format or text encoding; expected UTF-8 or valid Shift-JIS",
));
}
(
decoded.into_owned(),
Some("Decoded legacy text as Shift-JIS; original bytes are unchanged.".into()),
)
}
};
if text.contains('\0') {
return Err(ReadError::new(
"Unrecognized binary file (NUL bytes in text)",
));
}
let normalized = text
.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('\r', "\n");
let mut result = if normalized.starts_with("##LARIX:") {
if warning.is_some() {
return Err(ReadError::new("Larix session text must use UTF-8"));
}
larix::parse(&normalized)?
} else if xtunes::recognizes(&normalized) {
xtunes::parse(&normalized)?
} else if normalized.trim_start().starts_with("# Athena project")
|| normalized.trim_start().starts_with('{')
{
athena::parse(&normalized)?
} else {
text::parse(&normalized)?
};
if let Some(w) = warning {
result.warnings.push(w);
}
Ok(result)
}
fn measurement(format: &str, scans: Vec<MeasurementScan>) -> Measurement {
Measurement {
format: format.into(),
scans,
datasets: Vec::new(),
metadata: BTreeMap::new(),
warnings: Vec::new(),
}
}