1use std::fs::OpenOptions;
8use std::io::Write;
9use std::path::Path;
10
11use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
12use quick_xml::Writer;
13
14use crate::error::Result;
15use crate::header::{Header, StructuralHints};
16use crate::reader::SIGNATURE;
17use crate::value::Value;
18
19const OFFSET_WIDTH: usize = 12;
22
23impl Header {
24 #[must_use]
43 pub fn to_header_bytes(&self, hints: &StructuralHints) -> Vec<u8> {
44 self.render_container_header(hints, data_size(hints))
45 }
46
47 pub fn write_to_file<P: AsRef<Path>>(
87 &self,
88 path: P,
89 hints: &StructuralHints,
90 data: &[u8],
91 ) -> Result<()> {
92 let header_bytes = self.render_container_header(hints, data.len());
93 let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
94 file.write_all(&header_bytes)?;
95 file.write_all(data)?;
96 Ok(())
97 }
98
99 fn render_container_header(&self, hints: &StructuralHints, size: usize) -> Vec<u8> {
105 let placeholder = "0".repeat(OFFSET_WIDTH);
109 let xml_len = self.render_xml(hints, &placeholder, size).len();
110 let offset = 16 + xml_len;
111 let offset_str = format!("{offset:0width$}", width = OFFSET_WIDTH);
112 let xml = self.render_xml(hints, &offset_str, size);
113 debug_assert_eq!(xml.len(), xml_len, "offset width must not change length");
114
115 let mut out = Vec::with_capacity(16 + xml.len());
116 out.extend_from_slice(SIGNATURE);
117 out.extend_from_slice(&u32::try_from(xml.len()).unwrap_or(u32::MAX).to_le_bytes());
118 out.extend_from_slice(&[0u8; 4]); out.extend_from_slice(&xml);
120 out
121 }
122
123 pub fn update_file<P: AsRef<Path>>(
174 path: P,
175 edit: impl FnOnce(&mut Self) -> Result<()>,
176 ) -> Result<()> {
177 crate::splice::update_file(path, edit)
178 }
179
180 fn render_xml(&self, hints: &StructuralHints, offset_str: &str, size: usize) -> Vec<u8> {
182 const INFALLIBLE: &str = "writing XML to an in-memory buffer cannot fail";
183
184 let mut w = Writer::new(Vec::new());
185 w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
186 .expect(INFALLIBLE);
187
188 let mut xisf = BytesStart::new("xisf");
189 xisf.push_attribute(("version", "1.0"));
190 xisf.push_attribute(("xmlns", "http://www.pixinsight.com/xisf"));
191 w.write_event(Event::Start(xisf)).expect(INFALLIBLE);
192
193 let mut image = BytesStart::new("Image");
194 image.push_attribute(("geometry", hints.geometry.as_str()));
195 image.push_attribute(("sampleFormat", hints.sample_format.as_str()));
196 image.push_attribute(("colorSpace", hints.color_space.as_str()));
197 let location = format!("attachment:{offset_str}:{size}");
198 image.push_attribute(("location", location.as_str()));
199 w.write_event(Event::Start(image)).expect(INFALLIBLE);
200
201 for kw in &self.keywords {
202 let mut e = BytesStart::new("FITSKeyword");
203 e.push_attribute(("name", kw.name.as_str()));
204 if crate::keyword::is_commentary(&kw.name) {
205 e.push_attribute(("value", ""));
208 e.push_attribute(("comment", kw.value.text()));
209 } else {
210 let value = match &kw.value {
211 Value::Str(s) => format!("'{s}'"),
212 Value::Literal(s) => s.clone(),
213 };
214 e.push_attribute(("value", value.as_str()));
215 e.push_attribute(("comment", kw.comment.as_str()));
216 }
217 w.write_event(Event::Empty(e)).expect(INFALLIBLE);
218 }
219
220 for (id, p) in &self.properties {
221 let mut e = BytesStart::new("Property");
222 e.push_attribute(("id", id.as_str()));
223 e.push_attribute(("type", p.type_.as_str()));
224 e.push_attribute(("value", p.value.as_str()));
225 if !p.format.is_empty() {
226 e.push_attribute(("format", p.format.as_str()));
227 }
228 if !p.comment.is_empty() {
229 e.push_attribute(("comment", p.comment.as_str()));
230 }
231 w.write_event(Event::Empty(e)).expect(INFALLIBLE);
232 }
233
234 w.write_event(Event::End(BytesEnd::new("Image")))
235 .expect(INFALLIBLE);
236 w.write_event(Event::End(BytesEnd::new("xisf")))
237 .expect(INFALLIBLE);
238
239 w.into_inner()
240 }
241}
242
243fn data_size(hints: &StructuralHints) -> usize {
245 let samples: Option<usize> = hints
246 .geometry
247 .split(':')
248 .map(|d| d.trim().parse::<usize>().ok())
249 .collect::<Option<Vec<_>>>()
250 .map(|dims| dims.iter().product());
251 let samples = samples.filter(|&s| s > 0).unwrap_or(1);
252 samples
253 .saturating_mul(bytes_per_sample(&hints.sample_format))
254 .max(1)
255}
256
257fn bytes_per_sample(format: &str) -> usize {
259 match format {
260 "UInt16" | "Int16" => 2,
261 "UInt32" | "Int32" | "Float32" => 4,
262 "UInt64" | "Int64" | "Float64" | "Complex32" => 8,
263 "Complex64" => 16,
264 _ => 1, }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn hints(geometry: &str, sample_format: &str) -> StructuralHints {
273 StructuralHints {
274 geometry: geometry.to_owned(),
275 sample_format: sample_format.to_owned(),
276 color_space: "Gray".to_owned(),
277 }
278 }
279
280 #[test]
281 fn bytes_per_sample_matrix() {
282 for (format, bytes) in [
283 ("UInt8", 1),
284 ("Int8", 1),
285 ("UInt16", 2),
286 ("Int16", 2),
287 ("UInt32", 4),
288 ("Int32", 4),
289 ("Float32", 4),
290 ("UInt64", 8),
291 ("Int64", 8),
292 ("Float64", 8),
293 ("Complex32", 8),
294 ("Complex64", 16),
295 ("SomethingElse", 1),
296 ] {
297 assert_eq!(bytes_per_sample(format), bytes, "{format}");
298 }
299 }
300
301 #[test]
302 fn data_size_from_geometry() {
303 assert_eq!(data_size(&hints("1:1:1", "UInt8")), 1);
304 assert_eq!(data_size(&hints("100:100:3", "Float32")), 120_000);
305 assert_eq!(data_size(&hints("16:16:1", "UInt16")), 512);
306 assert_eq!(data_size(&hints("abc", "UInt8")), 1);
308 assert_eq!(data_size(&hints("0:0:0", "Float32")), 4);
309 assert_eq!(data_size(&hints("", "UInt8")), 1);
310 }
311
312 #[test]
313 fn header_only_output_has_no_data_block() {
314 let h = Header::new();
315 let hints = StructuralHints::default();
316 let bytes = h.to_header_bytes(&hints);
317 let xml_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
318 assert_eq!(bytes.len(), 16 + xml_len);
319 }
320
321 #[test]
322 fn attachment_offset_is_fixed_width_and_correct() {
323 let h = Header::new();
324 let bytes = h.to_header_bytes(&StructuralHints::default());
325 let xml_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
326 let xml = std::str::from_utf8(&bytes[16..16 + xml_len]).unwrap();
327 let offset = format!("{:0width$}", 16 + xml_len, width = OFFSET_WIDTH);
328 assert!(
329 xml.contains(&format!("attachment:{offset}:")),
330 "location must point right past the header: {xml}"
331 );
332 }
333
334 #[test]
335 fn xml_special_characters_round_trip() {
336 let mut h = Header::new();
337 h.set("OBJECT", "a<b&\"c'd").unwrap();
338 h.set_comment("OBJECT", "less < & \"quoted\"").unwrap();
339 h.set_property("Notes:Text", "x<y&z").unwrap();
340 let parsed = Header::parse(&h.to_header_bytes(&StructuralHints::default())).unwrap();
341 assert_eq!(parsed, h);
342 assert_eq!(parsed.get_str("OBJECT").unwrap(), Some("a<b&\"c'd"));
343 assert_eq!(parsed.property("Notes:Text"), Some("x<y&z"));
344 }
345
346 #[test]
347 fn empty_header_round_trips() {
348 let h = Header::new();
349 let parsed = Header::parse(&h.to_header_bytes(&StructuralHints::default())).unwrap();
350 assert_eq!(parsed, h);
351 }
352
353 #[test]
357 fn commentary_keywords_serialize_as_empty_value_with_comment_text() {
358 let mut h = Header::new();
359 h.append("HISTORY", "reduced with siril").unwrap();
360 h.append("COMMENT", "processed in PixInsight").unwrap();
361 let bytes = h.to_header_bytes(&StructuralHints::default());
362 let xml = std::str::from_utf8(&bytes[16..]).unwrap();
363
364 assert!(
365 xml.contains(r#"name="HISTORY" value="" comment="reduced with siril""#),
366 "xml: {xml}"
367 );
368 assert!(
369 xml.contains(r#"name="COMMENT" value="" comment="processed in PixInsight""#),
370 "xml: {xml}"
371 );
372 assert!(!xml.contains("'reduced with siril'"));
373 assert!(!xml.contains("'processed in PixInsight'"));
374 }
375
376 #[test]
379 fn non_commentary_keywords_are_unaffected() {
380 let mut h = Header::new();
381 h.set("OBJECT", "M31").unwrap();
382 h.set("GAIN", 100_i64).unwrap();
383 let bytes = h.to_header_bytes(&StructuralHints::default());
384 let xml = std::str::from_utf8(&bytes[16..]).unwrap();
385
386 assert!(
387 xml.contains(r#"name="OBJECT" value="'M31'""#),
388 "{xml}"
389 );
390 assert!(xml.contains(r#"name="GAIN" value="100""#), "{xml}");
391 }
392
393 #[test]
398 fn write_to_file_size_matches_data_len_not_hints() {
399 let h = Header::new();
400 let mismatched_hints = hints("4:4:1", "UInt16"); let data = [1u8, 2, 3]; let path = std::env::temp_dir().join(format!(
403 "xisf-header-writer-size-{}-{}.xisf",
404 std::process::id(),
405 line!()
406 ));
407 std::fs::remove_file(&path).ok();
408
409 h.write_to_file(&path, &mismatched_hints, &data).unwrap();
410
411 let bytes = std::fs::read(&path).unwrap();
412 let xml_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
413 let xml = std::str::from_utf8(&bytes[16..16 + xml_len]).unwrap();
414 assert!(
415 xml.contains(&format!(":{}\"", data.len())),
416 "SIZE must equal data.len() (3), not the hints-implied 32: {xml}"
417 );
418 assert_eq!(bytes.len(), 16 + xml_len + data.len());
419 assert_eq!(&bytes[bytes.len() - data.len()..], &data);
420
421 std::fs::remove_file(&path).ok();
422 }
423
424 #[test]
427 fn write_to_file_errors_if_path_exists() {
428 let h = Header::new();
429 let path = std::env::temp_dir().join(format!(
430 "xisf-header-writer-exists-{}-{}.xisf",
431 std::process::id(),
432 line!()
433 ));
434 std::fs::write(&path, b"pre-existing content").unwrap();
435
436 let err = h
437 .write_to_file(&path, &StructuralHints::default(), &[])
438 .unwrap_err();
439 assert!(matches!(
440 err,
441 crate::Error::Io(e) if e.kind() == std::io::ErrorKind::AlreadyExists
442 ));
443 assert_eq!(
444 std::fs::read(&path).unwrap(),
445 b"pre-existing content",
446 "the existing file must be left untouched"
447 );
448
449 std::fs::remove_file(&path).ok();
450 }
451}