Skip to main content

xisf_header/
writer.rs

1//! Serialization: emit an XISF container (or just its header) from a [`Header`].
2
3use std::path::Path;
4
5use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
6use quick_xml::Writer;
7
8use crate::error::Result;
9use crate::header::{Header, StructuralHints};
10use crate::reader::SIGNATURE;
11use crate::value::Value;
12
13/// Fixed width of the zero-padded attachment offset, so the rendered header
14/// length is independent of the offset's magnitude.
15const OFFSET_WIDTH: usize = 12;
16
17impl Header {
18    /// Serialize the header block — the 16-byte preamble plus the UTF-8 XML
19    /// header, with no data attached. The `<Image location>` points at the
20    /// byte offset immediately after the header, sized per `hints`, where a
21    /// caller assembling a new file appends the image data itself.
22    ///
23    /// `Header::parse(&header.to_header_bytes(&hints))` round-trips back to
24    /// `header`.
25    ///
26    /// ```
27    /// use xisf_header::{Header, StructuralHints};
28    ///
29    /// let mut header = Header::new();
30    /// header.set("IMAGETYP", "Master Dark").unwrap();
31    /// let hints = StructuralHints::default();
32    ///
33    /// let header_only = header.to_header_bytes(&hints);
34    /// assert_eq!(Header::parse(&header_only).unwrap(), header);
35    /// ```
36    #[must_use]
37    pub fn to_header_bytes(&self, hints: &StructuralHints) -> Vec<u8> {
38        let size = data_size(hints);
39
40        // Two-pass render: the attachment offset depends on the header length,
41        // which depends on the offset's text. A fixed-width offset keeps the
42        // length identical between passes.
43        let placeholder = "0".repeat(OFFSET_WIDTH);
44        let xml_len = self.render_xml(hints, &placeholder, size).len();
45        let offset = 16 + xml_len;
46        let offset_str = format!("{offset:0width$}", width = OFFSET_WIDTH);
47        let xml = self.render_xml(hints, &offset_str, size);
48        debug_assert_eq!(xml.len(), xml_len, "offset width must not change length");
49
50        let mut out = Vec::with_capacity(16 + xml.len());
51        out.extend_from_slice(SIGNATURE);
52        out.extend_from_slice(&u32::try_from(xml.len()).unwrap_or(u32::MAX).to_le_bytes());
53        out.extend_from_slice(&[0u8; 4]); // reserved
54        out.extend_from_slice(&xml);
55        out
56    }
57
58    /// Read a file's header, apply `edit`, and splice the result back into
59    /// the file in place: byte-exact and data-preserving. Every byte outside
60    /// the edited `<FITSKeyword>`/`<Property>` elements — unmodeled XML
61    /// (`Metadata`, `Resolution`, thumbnails, …), whitespace, and the
62    /// attached data block — survives untouched. A no-op edit reproduces the
63    /// input file byte-for-byte. If the header's XML length changes, the
64    /// `<Image location="attachment:OFFSET:SIZE">` offset is recomputed and
65    /// the original data bytes are moved (unchanged) to the new offset;
66    /// `SIZE` never changes.
67    ///
68    /// This requires the common single-image layout: exactly one `<Image
69    /// location="attachment:…">` element. A file with zero or multiple
70    /// attachments (e.g. a `Thumbnail` alongside the `Image`), or whose edit
71    /// needs to add elements to a self-closing `<Image/>`, is rejected with
72    /// [`Error::Unsupported`](crate::Error::Unsupported) rather than risking
73    /// data loss.
74    ///
75    /// The write is atomic — a sibling temp file is renamed over the target
76    /// — and follows symlinks (a symlinked `path` stays a symlink to the same
77    /// target) and preserves the target's unix permission mode.
78    ///
79    /// # Errors
80    ///
81    /// Propagates any error from reading or re-parsing the file, from
82    /// `edit`, or [`Error::Unsupported`](crate::Error::Unsupported) for a
83    /// layout the splice can't safely target. On error the file is left
84    /// untouched.
85    ///
86    /// ```
87    /// use xisf_header::{Header, StructuralHints};
88    ///
89    /// let path = std::env::temp_dir().join("xisf-header-doctest-update.xisf");
90    /// let mut header = Header::new();
91    /// header.set("IMAGETYP", "Master Dark")?;
92    /// let hints = StructuralHints::default(); // 1x1x1 UInt8 = 1 byte of data
93    /// let mut container = header.to_header_bytes(&hints);
94    /// container.push(0xAB); // the caller's own pixel data
95    /// std::fs::write(&path, &container)?;
96    ///
97    /// Header::update_file(&path, |h| {
98    ///     h.set("OBJECT", "NGC 7000")?;
99    ///     Ok(())
100    /// })?;
101    ///
102    /// assert_eq!(std::fs::read(&path)?.last(), Some(&0xAB)); // pixel data preserved
103    /// let edited = Header::read_from_file(&path)?;
104    /// assert_eq!(edited.get_str("OBJECT")?, Some("NGC 7000"));
105    /// # std::fs::remove_file(&path).ok();
106    /// # Ok::<(), xisf_header::Error>(())
107    /// ```
108    pub fn update_file<P: AsRef<Path>>(
109        path: P,
110        edit: impl FnOnce(&mut Self) -> Result<()>,
111    ) -> Result<()> {
112        crate::splice::update_file(path, edit)
113    }
114
115    /// Render the XML header. Writing to an in-memory `Vec` is infallible.
116    fn render_xml(&self, hints: &StructuralHints, offset_str: &str, size: usize) -> Vec<u8> {
117        const INFALLIBLE: &str = "writing XML to an in-memory buffer cannot fail";
118
119        let mut w = Writer::new(Vec::new());
120        w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
121            .expect(INFALLIBLE);
122
123        let mut xisf = BytesStart::new("xisf");
124        xisf.push_attribute(("version", "1.0"));
125        xisf.push_attribute(("xmlns", "http://www.pixinsight.com/xisf"));
126        w.write_event(Event::Start(xisf)).expect(INFALLIBLE);
127
128        let mut image = BytesStart::new("Image");
129        image.push_attribute(("geometry", hints.geometry.as_str()));
130        image.push_attribute(("sampleFormat", hints.sample_format.as_str()));
131        image.push_attribute(("colorSpace", hints.color_space.as_str()));
132        let location = format!("attachment:{offset_str}:{size}");
133        image.push_attribute(("location", location.as_str()));
134        w.write_event(Event::Start(image)).expect(INFALLIBLE);
135
136        for kw in &self.keywords {
137            let mut e = BytesStart::new("FITSKeyword");
138            e.push_attribute(("name", kw.name.as_str()));
139            let value = match &kw.value {
140                Value::Str(s) => format!("'{s}'"),
141                Value::Literal(s) => s.clone(),
142            };
143            e.push_attribute(("value", value.as_str()));
144            e.push_attribute(("comment", kw.comment.as_str()));
145            w.write_event(Event::Empty(e)).expect(INFALLIBLE);
146        }
147
148        for (id, p) in &self.properties {
149            let mut e = BytesStart::new("Property");
150            e.push_attribute(("id", id.as_str()));
151            e.push_attribute(("type", p.type_.as_str()));
152            e.push_attribute(("value", p.value.as_str()));
153            if !p.format.is_empty() {
154                e.push_attribute(("format", p.format.as_str()));
155            }
156            if !p.comment.is_empty() {
157                e.push_attribute(("comment", p.comment.as_str()));
158            }
159            w.write_event(Event::Empty(e)).expect(INFALLIBLE);
160        }
161
162        w.write_event(Event::End(BytesEnd::new("Image")))
163            .expect(INFALLIBLE);
164        w.write_event(Event::End(BytesEnd::new("xisf")))
165            .expect(INFALLIBLE);
166
167        w.into_inner()
168    }
169}
170
171/// Byte size of the data block implied by the hinted geometry and sample format.
172fn data_size(hints: &StructuralHints) -> usize {
173    let samples: Option<usize> = hints
174        .geometry
175        .split(':')
176        .map(|d| d.trim().parse::<usize>().ok())
177        .collect::<Option<Vec<_>>>()
178        .map(|dims| dims.iter().product());
179    let samples = samples.filter(|&s| s > 0).unwrap_or(1);
180    samples
181        .saturating_mul(bytes_per_sample(&hints.sample_format))
182        .max(1)
183}
184
185/// Bytes per sample for an XISF `sampleFormat`.
186fn bytes_per_sample(format: &str) -> usize {
187    match format {
188        "UInt16" | "Int16" => 2,
189        "UInt32" | "Int32" | "Float32" => 4,
190        "UInt64" | "Int64" | "Float64" | "Complex32" => 8,
191        "Complex64" => 16,
192        _ => 1, // UInt8/Int8 and anything unrecognized
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn hints(geometry: &str, sample_format: &str) -> StructuralHints {
201        StructuralHints {
202            geometry: geometry.to_owned(),
203            sample_format: sample_format.to_owned(),
204            color_space: "Gray".to_owned(),
205        }
206    }
207
208    #[test]
209    fn bytes_per_sample_matrix() {
210        for (format, bytes) in [
211            ("UInt8", 1),
212            ("Int8", 1),
213            ("UInt16", 2),
214            ("Int16", 2),
215            ("UInt32", 4),
216            ("Int32", 4),
217            ("Float32", 4),
218            ("UInt64", 8),
219            ("Int64", 8),
220            ("Float64", 8),
221            ("Complex32", 8),
222            ("Complex64", 16),
223            ("SomethingElse", 1),
224        ] {
225            assert_eq!(bytes_per_sample(format), bytes, "{format}");
226        }
227    }
228
229    #[test]
230    fn data_size_from_geometry() {
231        assert_eq!(data_size(&hints("1:1:1", "UInt8")), 1);
232        assert_eq!(data_size(&hints("100:100:3", "Float32")), 120_000);
233        assert_eq!(data_size(&hints("16:16:1", "UInt16")), 512);
234        // Malformed or zero geometry falls back to a single sample.
235        assert_eq!(data_size(&hints("abc", "UInt8")), 1);
236        assert_eq!(data_size(&hints("0:0:0", "Float32")), 4);
237        assert_eq!(data_size(&hints("", "UInt8")), 1);
238    }
239
240    #[test]
241    fn header_only_output_has_no_data_block() {
242        let h = Header::new();
243        let hints = StructuralHints::default();
244        let bytes = h.to_header_bytes(&hints);
245        let xml_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
246        assert_eq!(bytes.len(), 16 + xml_len);
247    }
248
249    #[test]
250    fn attachment_offset_is_fixed_width_and_correct() {
251        let h = Header::new();
252        let bytes = h.to_header_bytes(&StructuralHints::default());
253        let xml_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
254        let xml = std::str::from_utf8(&bytes[16..16 + xml_len]).unwrap();
255        let offset = format!("{:0width$}", 16 + xml_len, width = OFFSET_WIDTH);
256        assert!(
257            xml.contains(&format!("attachment:{offset}:")),
258            "location must point right past the header: {xml}"
259        );
260    }
261
262    #[test]
263    fn xml_special_characters_round_trip() {
264        let mut h = Header::new();
265        h.set("OBJECT", "a<b&\"c'd").unwrap();
266        h.set_comment("OBJECT", "less < & \"quoted\"").unwrap();
267        h.set_property("Notes:Text", "x<y&z").unwrap();
268        let parsed = Header::parse(&h.to_header_bytes(&StructuralHints::default())).unwrap();
269        assert_eq!(parsed, h);
270        assert_eq!(parsed.get_str("OBJECT").unwrap(), Some("a<b&\"c'd"));
271        assert_eq!(parsed.property("Notes:Text"), Some("x<y&z"));
272    }
273
274    #[test]
275    fn empty_header_round_trips() {
276        let h = Header::new();
277        let parsed = Header::parse(&h.to_header_bytes(&StructuralHints::default())).unwrap();
278        assert_eq!(parsed, h);
279    }
280}