use anyhow::{Context, Result};
use astro_io::fits::{header_cards_to_map, FitsHeaderCard};
use chrono::{DateTime, NaiveDateTime, Utc};
use log::warn;
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek};
use std::path::Path;
use super::coordinates::populate_header_coordinates;
use super::types::{AstroMetadata, AttachmentInfo, ColorManagement, DisplayFunction, XisfMetadata};
pub fn extract_metadata<R: Read + Seek>(reader: &mut R) -> Result<AstroMetadata> {
let mut metadata = AstroMetadata::default();
metadata.detector.binning_x = 1;
metadata.detector.binning_y = 1;
let mut raw_header_cards = Vec::new();
let mut xisf_metadata = XisfMetadata {
version: "1.0".to_string(),
creator: None,
creation_time: None,
block_alignment: None,
};
let mut signature = [0u8; 8];
reader
.read_exact(&mut signature)
.context("Failed to read XISF signature")?;
if &signature != b"XISF0100" {
return Err(anyhow::anyhow!("Invalid XISF signature"));
}
let mut header_size_bytes = [0u8; 4];
reader
.read_exact(&mut header_size_bytes)
.context("Failed to read header size")?;
let header_size = u32::from_le_bytes(header_size_bytes) as usize;
if let Ok(xml_content) = extract_xml_content(reader, header_size) {
extract_fits_keywords(&xml_content, &mut metadata, &mut raw_header_cards);
extract_xml_attributes(&xml_content, &mut metadata);
extract_xisf_metadata(&xml_content, &mut metadata, &mut xisf_metadata);
extract_color_management(&xml_content, &mut metadata);
extract_attachments(&xml_content, &mut metadata);
}
metadata.raw_headers = header_cards_to_map(&raw_header_cards);
populate_header_coordinates(&mut metadata.exposure, &metadata.raw_headers);
metadata.raw_header_cards = raw_header_cards;
metadata.xisf = Some(xisf_metadata);
metadata.calculate_session_date();
Ok(metadata)
}
pub fn extract_metadata_from_path(path: &Path) -> Result<AstroMetadata> {
let mut file = File::open(path).context("Failed to open XISF file")?;
extract_metadata(&mut file)
}
fn extract_xml_content<R: Read>(reader: &mut R, header_size: usize) -> Result<String> {
let mut header_data = vec![0u8; header_size];
reader
.read_exact(&mut header_data)
.context("Failed to read XML header")?;
let mut xml_start = 0;
for i in 0..header_data.len() {
if i + 5 < header_data.len() && &header_data[i..i + 5] == b"<?xml" {
xml_start = i;
break;
}
}
let actual_size = header_data[xml_start..]
.iter()
.position(|&b| b == 0)
.map(|pos| xml_start + pos)
.unwrap_or(header_data.len());
let xml_content = String::from_utf8_lossy(&header_data[xml_start..actual_size]).to_string();
Ok(xml_content)
}
fn extract_fits_keywords(
xml: &str,
metadata: &mut AstroMetadata,
raw_header_cards: &mut Vec<FitsHeaderCard>,
) {
let mut pos = 0;
while let Some(start_pos) = xml[pos..].find("<FITSKeyword ") {
let keyword_start = pos + start_pos;
if let Some(end_pos) = xml[keyword_start..].find("/>") {
let keyword_end = keyword_start + end_pos + 2;
let keyword_tag = &xml[keyword_start..keyword_end];
if let Some(name) = extract_attribute(keyword_tag, "name") {
if let Some(value) = extract_attribute(keyword_tag, "value") {
let clean_value = value.trim_matches('\'').to_string();
let card_index = raw_header_cards.len() + 1;
raw_header_cards.push(FitsHeaderCard {
hdu_index: 0,
card_index,
keyword: name.clone(),
value: Some(clean_value.clone()),
comment: None,
raw_card: None,
});
process_fits_keyword(metadata, &name, &clean_value);
}
}
pos = keyword_end;
} else {
break;
}
}
}
fn extract_xml_attributes(xml: &str, metadata: &mut AstroMetadata) {
if let Some(geometry) = extract_attribute(xml, "geometry") {
let parts: Vec<&str> = geometry.split(':').collect();
if parts.len() >= 2 {
metadata.detector.width = parts[0].parse().unwrap_or(0);
metadata.detector.height = parts[1].parse().unwrap_or(0);
}
}
if let Some(color_space) = extract_attribute(xml, "colorSpace") {
if color_space == "Gray" {
}
}
if let Some(sample_format) = extract_attribute(xml, "sampleFormat") {
if sample_format == "UInt16" {
}
}
if let Some(creator_app) = extract_property_value(xml, "XISF:CreatorApplication") {
if let Some(ref mut env) = metadata.environment {
env.software_version = Some(creator_app);
} else {
metadata.environment = Some(super::types::Environment {
software_version: Some(creator_app),
..Default::default()
});
}
}
}
fn extract_xisf_metadata(
xml: &str,
_metadata: &mut AstroMetadata,
xisf_metadata: &mut XisfMetadata,
) {
if let Some(version) = extract_attribute(xml, "version") {
xisf_metadata.version = version;
}
if let Some(creator_app) = extract_property_value(xml, "XISF:CreatorApplication") {
xisf_metadata.creator = Some(creator_app);
}
if let Some(creation_time) = extract_property_value(xml, "XISF:CreationTime") {
xisf_metadata.creation_time = parse_date_time(&creation_time);
}
if let Some(block_alignment) = extract_attribute(xml, "blockAlignment") {
xisf_metadata.block_alignment = block_alignment.parse::<usize>().ok();
}
}
fn extract_color_management(xml: &str, metadata: &mut AstroMetadata) {
let mut color_management = ColorManagement::default();
let mut has_color_info = false;
if let Some(color_space) = extract_attribute(xml, "colorSpace") {
color_management.color_space = Some(color_space);
has_color_info = true;
}
if let Some(_icc_profile) = extract_property_value(xml, "ICCProfile") {
color_management.icc_profile = Some(Vec::new());
has_color_info = true;
}
if let Some(display_function_type) = extract_attribute(xml, "displayFunction") {
let mut display_function = DisplayFunction {
function_type: Some(display_function_type),
..Default::default()
};
if let Some(params) = extract_attribute(xml, "displayParameters") {
let param_pairs: Vec<&str> = params.split(';').collect();
let mut parameters = HashMap::new();
for pair in param_pairs {
let kv: Vec<&str> = pair.split('=').collect();
if kv.len() == 2 {
if let Ok(value) = kv[1].parse::<f64>() {
parameters.insert(kv[0].to_string(), value);
}
}
}
display_function.parameters = parameters;
}
color_management.display_function = Some(display_function);
has_color_info = true;
}
if has_color_info {
metadata.color_management = Some(color_management);
}
}
fn extract_attachments(xml: &str, metadata: &mut AstroMetadata) {
let mut attachments = Vec::new();
let mut pos = 0;
while let Some(start_pos) = xml[pos..].find("<Image ") {
let image_start = pos + start_pos;
if let Some(end_pos) = xml[image_start..].find(">") {
let image_end = image_start + end_pos + 1;
let image_tag = &xml[image_start..image_end];
let mut attachment = AttachmentInfo::default();
if let Some(id) = extract_attribute(image_tag, "id") {
attachment.id = id;
} else {
attachment.id = format!("image{}", attachments.len());
}
if let Some(geometry) = extract_attribute(image_tag, "geometry") {
attachment.geometry = geometry;
}
if let Some(sample_format) = extract_attribute(image_tag, "sampleFormat") {
attachment.sample_format = sample_format;
} else {
attachment.sample_format = "UInt16".to_string(); }
if let Some(bits_per_sample) = extract_attribute(image_tag, "bitsPerSample") {
attachment.bits_per_sample = bits_per_sample.parse().unwrap_or(16);
} else {
attachment.bits_per_sample = 16; }
if let Some(compression) = extract_attribute(image_tag, "compression") {
attachment.compression = Some(compression);
if let Some(params) = extract_attribute(image_tag, "compressionParameters") {
let param_pairs: Vec<&str> = params.split(';').collect();
let mut parameters = HashMap::new();
for pair in param_pairs {
let kv: Vec<&str> = pair.split('=').collect();
if kv.len() == 2 {
parameters.insert(kv[0].to_string(), kv[1].to_string());
}
}
attachment.compression_parameters = parameters;
}
}
if let Some(checksum_type) = extract_attribute(image_tag, "checksumType") {
attachment.checksum_type = Some(checksum_type);
if let Some(checksum) = extract_attribute(image_tag, "checksum") {
attachment.checksum = Some(checksum);
}
}
if let Some(resolution_x) = extract_attribute(image_tag, "xResolution") {
attachment.resolution_x = resolution_x.parse::<f64>().ok();
if let Some(resolution_y) = extract_attribute(image_tag, "yResolution") {
attachment.resolution_y = resolution_y.parse::<f64>().ok();
}
if let Some(resolution_unit) = extract_attribute(image_tag, "resolutionUnit") {
attachment.resolution_unit = Some(resolution_unit);
}
}
attachments.push(attachment);
pos = image_end;
} else {
break;
}
}
if !attachments.is_empty() {
metadata.attachments = attachments;
}
}
fn process_fits_keyword(metadata: &mut AstroMetadata, name: &str, value: &str) {
match name {
"TELESCOP" => metadata.equipment.telescope_name = Some(value.to_string()),
"FOCALLEN" => metadata.equipment.focal_length = value.parse().ok(),
"APERTURE" => metadata.equipment.aperture = value.parse().ok(),
"FOCRATIO" => metadata.equipment.focal_ratio = value.parse().ok(),
"INSTRUME" | "CAMERA" => metadata.detector.camera_name = Some(value.to_string()),
"XPIXSZ" | "PIXSIZE" => metadata.detector.pixel_size = value.parse().ok(),
"XBINNING" => metadata.detector.binning_x = value.parse().unwrap_or(1),
"YBINNING" => metadata.detector.binning_y = value.parse().unwrap_or(1),
"GAIN" | "EGAIN" => metadata.detector.gain = value.parse().ok(),
"RDNOISE" => metadata.detector.read_noise = value.parse().ok(),
"CCD-TEMP" | "CCDTEMP" => metadata.detector.temperature = value.parse().ok(),
"SET-TEMP" => metadata.detector.temp_setpoint = value.parse().ok(),
"FILTER" => metadata.filter.name = Some(value.to_string()),
"OBJECT" => metadata.exposure.object_name = Some(value.to_string()),
"RA" | "OBJCTRA" | "DEC" | "OBJCTDEC" => {}
"DATE-OBS" => metadata.exposure.date_obs = parse_date_time(value),
"EXPTIME" | "EXPOSURE" => metadata.exposure.exposure_time = value.parse().ok(),
"IMAGETYP" | "FRAME" => metadata.exposure.frame_type = Some(value.to_string()),
"PIERSIDE" => {
if let Some(ref mut mount) = metadata.mount {
mount.pier_side = Some(value.to_string());
} else {
metadata.mount = Some(super::types::Mount {
pier_side: Some(value.to_string()),
..Default::default()
});
}
}
"AMB_TEMP" | "AMBTEMP" => {
if let Some(ref mut env) = metadata.environment {
env.ambient_temp = value.parse().ok();
} else {
metadata.environment = Some(super::types::Environment {
ambient_temp: value.parse().ok(),
..Default::default()
});
}
}
"HUMIDITY" => {
if let Some(ref mut env) = metadata.environment {
env.humidity = value.parse().ok();
} else {
metadata.environment = Some(super::types::Environment {
humidity: value.parse().ok(),
..Default::default()
});
}
}
"CRPIX1" => {
if let Some(ref mut wcs) = metadata.wcs {
wcs.crpix1 = value.parse().ok();
} else {
metadata.wcs = Some(super::types::WcsData {
crpix1: value.parse().ok(),
..Default::default()
});
}
}
"CRPIX2" => {
if let Some(ref mut wcs) = metadata.wcs {
wcs.crpix2 = value.parse().ok();
} else {
metadata.wcs = Some(super::types::WcsData {
crpix2: value.parse().ok(),
..Default::default()
});
}
}
"SITELAT" | "OBSLAT" => {
if let Some(ref mut mount) = metadata.mount {
mount.latitude = value.parse().ok();
} else {
metadata.mount = Some(super::types::Mount {
latitude: value.parse().ok(),
..Default::default()
});
}
}
"SITELONG" | "OBSLONG" => {
if let Some(ref mut mount) = metadata.mount {
mount.longitude = value.parse().ok();
} else {
metadata.mount = Some(super::types::Mount {
longitude: value.parse().ok(),
..Default::default()
});
}
}
"SITEELEV" | "OBSELEV" => {
if let Some(ref mut mount) = metadata.mount {
mount.height = value.parse().ok();
} else {
metadata.mount = Some(super::types::Mount {
height: value.parse().ok(),
..Default::default()
});
}
}
"OFFSET" | "CCDOFFST" => metadata.detector.offset = value.parse().ok(),
"READOUT" | "READOUTM" => metadata.detector.readout_mode = Some(value.to_string()),
"USBLIMIT" | "USBTRFC" => metadata.detector.usb_limit = Some(value.to_string()),
"ROTANG" | "ROTPA" | "ROTATANG" => metadata.detector.rotator_angle = value.parse().ok(),
"FOCPOS" | "FOCUSPOS" => metadata.equipment.focuser_position = value.parse().ok(),
"FOCTEMP" | "FOCUSTEMP" => metadata.equipment.focuser_temperature = value.parse().ok(),
"PEAKRA" | "PEAKRAER" => {
if let Some(ref mut mount) = metadata.mount {
mount.peak_ra_error = value.parse().ok();
} else {
metadata.mount = Some(super::types::Mount {
peak_ra_error: value.parse().ok(),
..Default::default()
});
}
}
"PEAKDEC" | "PEAKDCER" => {
if let Some(ref mut mount) = metadata.mount {
mount.peak_dec_error = value.parse().ok();
} else {
metadata.mount = Some(super::types::Mount {
peak_dec_error: value.parse().ok(),
..Default::default()
});
}
}
"SQM" | "SQMMAG" | "SKYQUAL" => {
if let Some(ref mut env) = metadata.environment {
env.sqm = value.parse().ok();
} else {
metadata.environment = Some(super::types::Environment {
sqm: value.parse().ok(),
..Default::default()
});
}
}
"PROJECT" | "PROJNAME" => metadata.exposure.project_name = Some(value.to_string()),
"SESSIONID" | "SESSID" => metadata.exposure.session_id = Some(value.to_string()),
_ => {}
}
}
fn extract_attribute(xml: &str, attr_name: &str) -> Option<String> {
let search_pattern = format!("{}=\"", attr_name);
if let Some(start_pos) = xml.find(&search_pattern) {
let start = start_pos + search_pattern.len();
if let Some(end_pos) = xml[start..].find('"') {
return Some(xml[start..start + end_pos].to_string());
}
}
None
}
fn extract_property_value(xml: &str, property_id: &str) -> Option<String> {
let search_pattern = format!("id=\"{}\" type=\"", property_id);
if let Some(start_pos) = xml.find(&search_pattern) {
if let Some(tag_end) = xml[start_pos..].find(">") {
let tag_end_pos = start_pos + tag_end + 1;
if let Some(end_tag_pos) = xml[tag_end_pos..].find("</Property>") {
let value_end = tag_end_pos + end_tag_pos;
return Some(xml[tag_end_pos..value_end].trim().to_string());
}
}
}
None
}
fn parse_date_time(date_str: &str) -> Option<DateTime<Utc>> {
let formats = [
"%Y-%m-%dT%H:%M:%S%.fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%d %H:%M:%S", ];
for format in &formats {
if let Ok(dt) = NaiveDateTime::parse_from_str(date_str, format) {
return Some(DateTime::from_naive_utc_and_offset(dt, Utc));
}
}
warn!("Failed to parse date string: {}", date_str);
None
}
#[cfg(test)]
mod tests {
use super::extract_metadata;
use chrono::{TimeZone, Utc};
use std::io::Cursor;
#[test]
fn test_creation_time_does_not_override_observation_date() {
let xml = concat!(
"<?xml version=\"1.0\"?>",
"<xisf version=\"1.0\">",
"<Image geometry=\"2:2:1\" sampleFormat=\"UInt16\">",
"<FITSKeyword name=\"DATE-OBS\" value=\"2024-09-04T08:39:13.204\"/>",
"<FITSKeyword name=\"EXPTIME\" value=\"60.\"/>",
"</Image>",
"<Property id=\"XISF:CreationTime\" type=\"String\">2024-09-06T10:31:17</Property>",
"<Property id=\"XISF:CreatorApplication\" type=\"String\">PixInsight 1.8.9-3</Property>",
"</xisf>"
);
let header_size = xml.len() as u32;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"XISF0100");
bytes.extend_from_slice(&header_size.to_le_bytes());
bytes.extend_from_slice(xml.as_bytes());
let metadata = extract_metadata(&mut Cursor::new(bytes)).expect("metadata should parse");
assert_eq!(
metadata.exposure.date_obs,
Some(
Utc.with_ymd_and_hms(2024, 9, 4, 8, 39, 13).unwrap()
+ chrono::Duration::milliseconds(204)
)
);
assert_eq!(
metadata.xisf.as_ref().and_then(|xisf| xisf.creation_time),
Some(Utc.with_ymd_and_hms(2024, 9, 6, 10, 31, 17).unwrap())
);
}
#[test]
fn test_missing_binning_defaults_to_one() {
let xml = concat!(
"<?xml version=\"1.0\"?>",
"<xisf version=\"1.0\">",
"<Image geometry=\"2:2:1\" sampleFormat=\"UInt16\">",
"<FITSKeyword name=\"OBJECT\" value=\"M31\"/>",
"</Image>",
"</xisf>"
);
let header_size = xml.len() as u32;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"XISF0100");
bytes.extend_from_slice(&header_size.to_le_bytes());
bytes.extend_from_slice(xml.as_bytes());
let metadata = extract_metadata(&mut Cursor::new(bytes)).expect("metadata should parse");
assert_eq!(metadata.detector.binning_x, 1);
assert_eq!(metadata.detector.binning_y, 1);
}
#[test]
fn test_coordinate_keyword_pairs_are_preserved_independently() {
let xml = concat!(
"<?xml version=\"1.0\"?>",
"<xisf version=\"1.0\">",
"<Image geometry=\"2:2:1\" sampleFormat=\"UInt16\">",
"<FITSKeyword name=\"RA\" value=\"237.502568422944\"/>",
"<FITSKeyword name=\"DEC\" value=\"43.8990616679659\"/>",
"<FITSKeyword name=\"OBJCTRA\" value=\"15 50 01\"/>",
"<FITSKeyword name=\"OBJCTDEC\" value=\"+43 53 57\"/>",
"</Image>",
"</xisf>"
);
let header_size = xml.len() as u32;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"XISF0100");
bytes.extend_from_slice(&header_size.to_le_bytes());
bytes.extend_from_slice(xml.as_bytes());
let metadata = extract_metadata(&mut Cursor::new(bytes)).expect("metadata should parse");
assert_eq!(
metadata.exposure.header_coordinates.ra_dec.ra,
Some(237.502568422944)
);
assert_eq!(
metadata.exposure.header_coordinates.ra_dec.dec,
Some(43.8990616679659)
);
assert!(
(metadata
.exposure
.header_coordinates
.objctra_objctdec
.ra
.unwrap()
- 237.50416666666666)
.abs()
< 0.000_000_001
);
assert!(
(metadata
.exposure
.header_coordinates
.objctra_objctdec
.dec
.unwrap()
- 43.899166666666666)
.abs()
< 0.000_000_001
);
}
}