Skip to main content

fits_header/
write.rs

1//! Serialize a header back to bytes.
2
3use crate::error::FitsError;
4use crate::header::Header;
5use crate::record::{Record, RecordKind, Value};
6use crate::{BLOCK_LEN, CARD_LEN};
7
8/// Largest data segment [`Header::to_bytes`] will zero-fill, in bytes (1 GiB).
9///
10/// A header can declare an arbitrarily large image; zero-filling it would allocate that many
11/// bytes from header content alone. Above this cap `to_bytes` returns
12/// [`FitsError::DataTooLarge`] — serialize with [`Header::to_header_bytes`] and supply the
13/// data yourself.
14pub const MAX_ZERO_FILL: u64 = 1 << 30;
15
16/// Image geometry used only when [`Header::to_bytes`] must synthesize missing structural cards.
17#[derive(Clone, Debug, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct StructuralHints {
20    /// `BITPIX` — bits per pixel (sign encodes integer vs. float).
21    pub bitpix: i64,
22    /// `NAXIS1` — first axis length.
23    pub naxis1: u32,
24    /// `NAXIS2` — second axis length.
25    pub naxis2: u32,
26}
27
28impl Default for StructuralHints {
29    /// A 1×1 8-bit image.
30    fn default() -> Self {
31        StructuralHints {
32            bitpix: 8,
33            naxis1: 1,
34            naxis2: 1,
35        }
36    }
37}
38
39/// Serialize the header block only (cards + `END`, padded to a 2880 multiple).
40pub fn to_header_bytes(header: &Header) -> Vec<u8> {
41    let mut out = Vec::new();
42    emit_records(&mut out, header.cards(), has_longstrn(header));
43    finish_header(&mut out);
44    out
45}
46
47/// Serialize a standalone FITS object (header + minimal zero data block).
48///
49/// Errors with [`FitsError::DataTooLarge`] when the declared data segment exceeds
50/// [`MAX_ZERO_FILL`].
51pub fn to_bytes(header: &Header, hints: &StructuralHints) -> Result<Vec<u8>, FitsError> {
52    let synth = !header.cards().iter().any(|r| r.keyword() == Some("SIMPLE"));
53
54    let declared = data_len(header, hints, synth);
55    if declared > MAX_ZERO_FILL {
56        return Err(FitsError::DataTooLarge {
57            declared,
58            max: MAX_ZERO_FILL,
59        });
60    }
61
62    let mut out = Vec::new();
63    if synth {
64        push(
65            &mut out,
66            literal_card("SIMPLE", "T", Some("conforms to FITS standard")),
67        );
68        push(
69            &mut out,
70            literal_card("BITPIX", &hints.bitpix.to_string(), None),
71        );
72        push(&mut out, literal_card("NAXIS", "2", None));
73        push(
74            &mut out,
75            literal_card("NAXIS1", &hints.naxis1.to_string(), None),
76        );
77        push(
78            &mut out,
79            literal_card("NAXIS2", &hints.naxis2.to_string(), None),
80        );
81    }
82    emit_records(&mut out, header.cards(), has_longstrn(header));
83    finish_header(&mut out);
84
85    let mut data = vec![0u8; declared as usize];
86    if !data.is_empty() {
87        pad_to_block(&mut data, 0);
88    }
89    out.extend_from_slice(&data);
90    Ok(out)
91}
92
93fn finish_header(out: &mut Vec<u8>) {
94    push(out, pad80("END"));
95    pad_to_block(out, b' ');
96}
97
98fn emit_records(out: &mut Vec<u8>, records: &[Record], longstrn_present: bool) {
99    let mut longstrn_done = longstrn_present;
100    for record in records {
101        let (cards, used_continue) = render_record(record);
102        if used_continue && !longstrn_done {
103            push(out, longstrn_card());
104            longstrn_done = true;
105        }
106        for card in cards {
107            out.extend_from_slice(&card);
108        }
109    }
110}
111
112fn render_record(record: &Record) -> (Vec<[u8; CARD_LEN]>, bool) {
113    if let Some(raw) = record.raw_cards() {
114        return (raw.to_vec(), false);
115    }
116    match &record.kind {
117        RecordKind::Value {
118            keyword,
119            value: Value::Str(s),
120            comment,
121        } => string_cards(keyword, s, comment.as_deref()),
122        RecordKind::Value {
123            keyword,
124            value: Value::Literal(l),
125            comment,
126        } => (vec![literal_card(keyword, l, comment.as_deref())], false),
127        RecordKind::Commentary { keyword, text } => (commentary_cards(keyword, text), false),
128        RecordKind::Opaque { text } => (vec![pad80(text)], false),
129    }
130}
131
132fn literal_card(keyword: &str, token: &str, comment: Option<&str>) -> [u8; CARD_LEN] {
133    let mut s = format!("{keyword:<8}= ");
134    if token.len() <= 20 {
135        s.push_str(&format!("{token:>20}"));
136    } else {
137        s.push_str(token);
138    }
139    if let Some(c) = comment {
140        s.push_str(&format!(" / {c}"));
141    }
142    pad80(&s)
143}
144
145fn string_cards(
146    keyword: &str,
147    content: &str,
148    comment: Option<&str>,
149) -> (Vec<[u8; CARD_LEN]>, bool) {
150    if let Some(card) = string_single(keyword, content, comment) {
151        return (vec![card], false);
152    }
153    let pieces = chunk_for_continue(content);
154    let mut cards = Vec::new();
155    let last_idx = pieces.len() - 1;
156    for (idx, piece) in pieces.iter().enumerate() {
157        let esc = piece.replace('\'', "''");
158        let body = if idx == last_idx {
159            format!("'{esc}'")
160        } else {
161            format!("'{esc}&'")
162        };
163        let mut s = if idx == 0 {
164            format!("{keyword:<8}= {body}")
165        } else {
166            format!("{:<8}  {body}", "CONTINUE")
167        };
168        if idx == last_idx {
169            if let Some(c) = comment {
170                let with = format!("{s} / {c}");
171                if with.len() <= CARD_LEN {
172                    s = with;
173                }
174            }
175        }
176        cards.push(pad80(&s));
177    }
178    (cards, true)
179}
180
181fn string_single(keyword: &str, content: &str, comment: Option<&str>) -> Option<[u8; CARD_LEN]> {
182    let esc = content.replace('\'', "''");
183    let inner = format!("{esc:<8}");
184    let mut s = format!("{keyword:<8}= '{inner}'");
185    if let Some(c) = comment {
186        s.push_str(&format!(" / {c}"));
187    }
188    (s.len() <= CARD_LEN).then(|| pad80(&s))
189}
190
191/// Split content so each escaped piece fits a continuation card's value field.
192fn chunk_for_continue(content: &str) -> Vec<String> {
193    let mut pieces = Vec::new();
194    let mut cur = String::new();
195    let mut esc_len = 0usize;
196    for ch in content.chars() {
197        let add = if ch == '\'' { 2 } else { 1 };
198        if esc_len + add > 66 {
199            pieces.push(std::mem::take(&mut cur));
200            esc_len = 0;
201        }
202        cur.push(ch);
203        esc_len += add;
204    }
205    pieces.push(cur);
206    pieces
207}
208
209fn commentary_cards(keyword: &str, text: &str) -> Vec<[u8; CARD_LEN]> {
210    if text.is_empty() {
211        return vec![pad80(&format!("{keyword:<8}"))];
212    }
213    let chars: Vec<char> = text.chars().collect();
214    chars
215        .chunks(72)
216        .map(|chunk| {
217            let t: String = chunk.iter().collect();
218            pad80(&format!("{keyword:<8}{t}"))
219        })
220        .collect()
221}
222
223fn longstrn_card() -> [u8; CARD_LEN] {
224    pad80(&format!(
225        "{:<8}= '{:<8}' / {}",
226        "LONGSTRN", "OGIP 1.0", "The OGIP long string convention may be used."
227    ))
228}
229
230fn has_longstrn(header: &Header) -> bool {
231    header
232        .cards()
233        .iter()
234        .any(|r| r.keyword() == Some("LONGSTRN"))
235}
236
237/// The data size a header declares, in bytes (saturated on overflow, so a pathological
238/// geometry reads as "too large" instead of wrapping).
239fn data_len(header: &Header, hints: &StructuralHints, synth: bool) -> u64 {
240    let (bitpix, axes): (i64, Vec<u64>) = if synth {
241        (hints.bitpix, vec![hints.naxis1 as u64, hints.naxis2 as u64])
242    } else {
243        let bitpix = header.get::<i64>("BITPIX").ok().flatten().unwrap_or(8);
244        let naxis = header
245            .get::<i64>("NAXIS")
246            .ok()
247            .flatten()
248            .unwrap_or(0)
249            .max(0) as usize;
250        let axes = (1..=naxis)
251            .map(|k| {
252                header
253                    .get::<u64>(format!("NAXIS{k}"))
254                    .ok()
255                    .flatten()
256                    .unwrap_or(0)
257            })
258            .collect();
259        (bitpix, axes)
260    };
261    if axes.is_empty() || axes.contains(&0) {
262        return 0;
263    }
264    let elt = bitpix.unsigned_abs() / 8;
265    axes.iter().fold(elt, |acc, &n| acc.saturating_mul(n))
266}
267
268fn push(out: &mut Vec<u8>, card: [u8; CARD_LEN]) {
269    out.extend_from_slice(&card);
270}
271
272fn pad_to_block(out: &mut Vec<u8>, fill: u8) {
273    while out.len() % BLOCK_LEN != 0 {
274        out.push(fill);
275    }
276}
277
278/// Left-justify `s` into an 80-byte card, space-padded (truncated at 80 bytes).
279fn pad80(s: &str) -> [u8; CARD_LEN] {
280    let mut card = [b' '; CARD_LEN];
281    let bytes = s.as_bytes();
282    let n = bytes.len().min(CARD_LEN);
283    card[..n].copy_from_slice(&bytes[..n]);
284    card
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn text(card: &[u8; CARD_LEN]) -> String {
292        String::from_utf8_lossy(card).into_owned()
293    }
294
295    #[test]
296    fn pad80_pads_and_truncates() {
297        assert_eq!(&pad80("END")[..3], b"END");
298        assert!(pad80("END")[3..].iter().all(|&b| b == b' '));
299        let long = "x".repeat(100);
300        assert_eq!(pad80(&long), [b'x'; CARD_LEN]);
301    }
302
303    #[test]
304    fn literal_card_right_justifies_short_tokens() {
305        let c = text(&literal_card("BITPIX", "8", Some("bits")));
306        assert!(c.starts_with("BITPIX  =                    8 / bits"));
307        // A token wider than the fixed 20-char field is emitted unpadded.
308        let wide = "1234567890123456789012345";
309        let c = text(&literal_card("K", wide, None));
310        assert!(c.starts_with(&format!("K       = {wide}")));
311    }
312
313    #[test]
314    fn string_single_pads_content_to_eight() {
315        let c = text(&string_single("OBJECT", "M31", None).unwrap());
316        assert!(c.starts_with("OBJECT  = 'M31     '"));
317    }
318
319    #[test]
320    fn string_single_rejects_overflow() {
321        // 69 escaped chars + quotes + "KEY     = " no longer fit in 80.
322        assert!(string_single("KEY", &"x".repeat(68), None).is_some());
323        assert!(string_single("KEY", &"x".repeat(69), None).is_none());
324    }
325
326    #[test]
327    fn string_single_drops_to_continue_when_comment_overflows() {
328        let content = "x".repeat(60);
329        assert!(string_single("KEY", &content, Some(&"c".repeat(20))).is_none());
330    }
331
332    #[test]
333    fn chunks_fit_continuation_cards_and_split_escapes() {
334        // Doubled quotes count as two; every escaped piece must fit 66 columns.
335        let content = "'".repeat(50) + &"a".repeat(50);
336        for piece in chunk_for_continue(&content) {
337            let esc = piece.replace('\'', "''");
338            assert!(esc.len() <= 66, "escaped piece too long: {}", esc.len());
339        }
340        assert_eq!(chunk_for_continue(&content).concat(), content);
341    }
342
343    #[test]
344    fn commentary_chunks_at_72() {
345        let cards = commentary_cards("COMMENT", &"y".repeat(100));
346        assert_eq!(cards.len(), 2);
347        assert!(text(&cards[0]).starts_with(&format!("COMMENT {}", "y".repeat(72))));
348        assert!(text(&cards[1]).starts_with(&format!("COMMENT {}", "y".repeat(28))));
349        // Empty commentary still emits its bare keyword card.
350        assert_eq!(commentary_cards("HISTORY", "").len(), 1);
351    }
352
353    #[test]
354    fn data_len_geometry() {
355        let hints = StructuralHints::default();
356        // Synthesized: 1×1 8-bit → 1 byte.
357        assert_eq!(data_len(&Header::new(), &hints, true), 1);
358
359        let mut h = Header::new();
360        h.set("SIMPLE", crate::value::Literal("T")).unwrap();
361        // No NAXIS → no data.
362        assert_eq!(data_len(&h, &hints, false), 0);
363        // A zero axis → no data.
364        h.set("BITPIX", -32).unwrap();
365        h.set("NAXIS", 2).unwrap();
366        h.set("NAXIS1", 100).unwrap();
367        h.set("NAXIS2", 0).unwrap();
368        assert_eq!(data_len(&h, &hints, false), 0);
369        // Negative BITPIX (float) still sizes by magnitude: 100×50×4.
370        h.set("NAXIS2", 50).unwrap();
371        assert_eq!(data_len(&h, &hints, false), 100 * 50 * 4);
372        // A missing NAXISk reads as 0 → no data.
373        h.set("NAXIS", 3).unwrap();
374        assert_eq!(data_len(&h, &hints, false), 0);
375    }
376
377    #[test]
378    fn longstrn_emitted_once_before_first_continue() {
379        let mut h = Header::new();
380        h.set("A", "x".repeat(100).as_str()).unwrap();
381        h.set("B", "y".repeat(100).as_str()).unwrap();
382        let out = to_header_bytes(&h);
383        let s = String::from_utf8_lossy(&out);
384        assert_eq!(s.matches("LONGSTRN").count(), 1);
385        // LONGSTRN precedes the first long-string card.
386        assert!(s.find("LONGSTRN").unwrap() < s.find('A').unwrap_or(usize::MAX));
387    }
388
389    #[test]
390    fn existing_longstrn_not_duplicated() {
391        let mut h = Header::new();
392        h.set("LONGSTRN", "OGIP 1.0").unwrap();
393        h.set("A", "x".repeat(100).as_str()).unwrap();
394        let out = to_header_bytes(&h);
395        let s = String::from_utf8_lossy(&out);
396        assert_eq!(s.matches("LONGSTRN").count(), 1);
397    }
398}