Skip to main content

xisf_header/
writer.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Serialization: emit an XISF container (or just its header) from a [`Header`].
6
7use 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
19/// Fixed width of the zero-padded attachment offset, so the rendered header
20/// length is independent of the offset's magnitude.
21const OFFSET_WIDTH: usize = 12;
22
23impl Header {
24    /// Serialize the header block — the 16-byte preamble plus the UTF-8 XML
25    /// header, with no data attached. The `<Image location>` points at the
26    /// byte offset immediately after the header, sized per `hints`, where a
27    /// caller assembling a new file appends the image data itself.
28    ///
29    /// `Header::parse(&header.to_header_bytes(&hints))` round-trips back to
30    /// `header`.
31    ///
32    /// ```
33    /// use xisf_header::{Header, StructuralHints};
34    ///
35    /// let mut header = Header::new();
36    /// header.set("IMAGETYP", "Master Dark").unwrap();
37    /// let hints = StructuralHints::default();
38    ///
39    /// let header_only = header.to_header_bytes(&hints);
40    /// assert_eq!(Header::parse(&header_only).unwrap(), header);
41    /// ```
42    #[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    /// Write a complete XISF container to a **new** file: the preamble + XML
48    /// header (with the `<Image>` element from `hints`, and a `location`
49    /// attachment `SIZE` equal to `data.len()`) followed by `data` verbatim.
50    ///
51    /// `data` is the caller's own pixel bytes — this crate never fabricates
52    /// image data. Pass `&[]` for a header-only container (`SIZE` 0).
53    ///
54    /// This creates a **new file only**: it fails with
55    /// [`Error::Io`](crate::Error::Io) (`ErrorKind::AlreadyExists`) if `path`
56    /// already exists, rather than overwriting it. To edit an existing file's
57    /// header in place, use [`update_file`](Self::update_file) instead.
58    ///
59    /// # Errors
60    ///
61    /// Propagates any I/O error opening or writing `path`, including
62    /// `AlreadyExists` when the path already exists.
63    ///
64    /// ```
65    /// use xisf_header::{Header, StructuralHints};
66    ///
67    /// let path = std::env::temp_dir().join("xisf-header-doctest-write.xisf");
68    /// # std::fs::remove_file(&path).ok();
69    /// let mut header = Header::new();
70    /// header.set("IMAGETYP", "Master Dark")?;
71    /// let hints = StructuralHints::default();
72    /// let data = [0u8; 4]; // the caller's own pixel bytes
73    ///
74    /// header.write_to_file(&path, &hints, &data)?;
75    ///
76    /// let bytes = std::fs::read(&path)?;
77    /// assert_eq!(&bytes[bytes.len() - 4..], &data);
78    /// let reloaded = Header::read_from_file(&path)?;
79    /// assert_eq!(reloaded, header);
80    ///
81    /// // A second write to the same path never clobbers it.
82    /// assert!(header.write_to_file(&path, &hints, &data).is_err());
83    /// # std::fs::remove_file(&path).ok();
84    /// # Ok::<(), xisf_header::Error>(())
85    /// ```
86    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    /// Render the preamble + XML header for a container whose data block is
100    /// `size` bytes, per `hints`. Shared by [`to_header_bytes`](Self::to_header_bytes)
101    /// (`size` derived from `hints`' geometry) and
102    /// [`write_to_file`](Self::write_to_file) (`size` = the caller's actual
103    /// `data.len()`, so the emitted `SIZE` always matches the bytes on disk).
104    fn render_container_header(&self, hints: &StructuralHints, size: usize) -> Vec<u8> {
105        // Two-pass render: the attachment offset depends on the header length,
106        // which depends on the offset's text. A fixed-width offset keeps the
107        // length identical between passes.
108        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]); // reserved
119        out.extend_from_slice(&xml);
120        out
121    }
122
123    /// Read a file's header, apply `edit`, and splice the result back into
124    /// the file in place: byte-exact and data-preserving. Every byte outside
125    /// the edited `<FITSKeyword>`/`<Property>` elements — unmodeled XML
126    /// (`Metadata`, `Resolution`, thumbnails, …), whitespace, and the
127    /// attached data block — survives untouched. A no-op edit reproduces the
128    /// input file byte-for-byte. If the header's XML length changes, the
129    /// `<Image location="attachment:OFFSET:SIZE">` offset is recomputed and
130    /// the original data bytes are moved (unchanged) to the new offset;
131    /// `SIZE` never changes.
132    ///
133    /// This requires the common single-image layout: exactly one `<Image
134    /// location="attachment:…">` element. A file with zero or multiple
135    /// attachments (e.g. a `Thumbnail` alongside the `Image`), or whose edit
136    /// needs to add elements to a self-closing `<Image/>`, is rejected with
137    /// [`Error::Unsupported`](crate::Error::Unsupported) rather than risking
138    /// data loss.
139    ///
140    /// The write is atomic — a sibling temp file is renamed over the target
141    /// — and follows symlinks (a symlinked `path` stays a symlink to the same
142    /// target) and preserves the target's unix permission mode.
143    ///
144    /// # Errors
145    ///
146    /// Propagates any error from reading or re-parsing the file, from
147    /// `edit`, or [`Error::Unsupported`](crate::Error::Unsupported) for a
148    /// layout the splice can't safely target. On error the file is left
149    /// untouched.
150    ///
151    /// ```
152    /// use xisf_header::{Header, StructuralHints};
153    ///
154    /// let path = std::env::temp_dir().join("xisf-header-doctest-update.xisf");
155    /// let mut header = Header::new();
156    /// header.set("IMAGETYP", "Master Dark")?;
157    /// let hints = StructuralHints::default(); // 1x1x1 UInt8 = 1 byte of data
158    /// let mut container = header.to_header_bytes(&hints);
159    /// container.push(0xAB); // the caller's own pixel data
160    /// std::fs::write(&path, &container)?;
161    ///
162    /// Header::update_file(&path, |h| {
163    ///     h.set("OBJECT", "NGC 7000")?;
164    ///     Ok(())
165    /// })?;
166    ///
167    /// assert_eq!(std::fs::read(&path)?.last(), Some(&0xAB)); // pixel data preserved
168    /// let edited = Header::read_from_file(&path)?;
169    /// assert_eq!(edited.get_str("OBJECT")?, Some("NGC 7000"));
170    /// # std::fs::remove_file(&path).ok();
171    /// # Ok::<(), xisf_header::Error>(())
172    /// ```
173    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    /// Render the XML header. Writing to an in-memory `Vec` is infallible.
181    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                // FITS commentary keywords have no value: the free text lives
206                // in `comment`, with an empty `value` (see `is_commentary`).
207                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
243/// Byte size of the data block implied by the hinted geometry and sample format.
244fn 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
257/// Bytes per sample for an XISF `sampleFormat`.
258fn 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, // UInt8/Int8 and anything unrecognized
265    }
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        // Malformed or zero geometry falls back to a single sample.
307        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    /// HISTORY/COMMENT have no FITS value — the text must land in the
354    /// `comment` attribute with an empty `value`, not a quoted `value` (the
355    /// bug this fix corrects: the old form was malformed FITS commentary).
356    #[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("&apos;reduced with siril&apos;"));
373        assert!(!xml.contains("&apos;processed in PixInsight&apos;"));
374    }
375
376    /// Non-commentary keywords keep their pre-fix serialization: strings
377    /// quoted in `value`, numbers bare, `comment` used for the FITS comment.
378    #[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="&apos;M31&apos;""#),
388            "{xml}"
389        );
390        assert!(xml.contains(r#"name="GAIN" value="100""#), "{xml}");
391    }
392
393    /// `write_to_file`'s emitted `SIZE` must track the caller's actual
394    /// `data.len()`, not the size implied by `hints`' geometry — the two can
395    /// legitimately diverge (e.g. a caller passing header-only `&[]` against
396    /// non-trivial hints).
397    #[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"); // implies 32 bytes
401        let data = [1u8, 2, 3]; // actual payload is 3 bytes
402        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    /// `write_to_file` must never clobber an existing file — the crate's only
425    /// path for editing an existing file is `update_file`.
426    #[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}