1use crate::error::FitsError;
4use crate::header::Header;
5use crate::record::{Record, RecordKind, Value};
6use crate::{BLOCK_LEN, CARD_LEN};
7
8pub const MAX_ZERO_FILL: u64 = 1 << 30;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct StructuralHints {
20 pub bitpix: i64,
22 pub naxis1: u32,
24 pub naxis2: u32,
26}
27
28impl Default for StructuralHints {
29 fn default() -> Self {
31 StructuralHints {
32 bitpix: 8,
33 naxis1: 1,
34 naxis2: 1,
35 }
36 }
37}
38
39pub 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
47pub 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
191fn 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
237fn 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
278fn 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 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 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 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 assert_eq!(commentary_cards("HISTORY", "").len(), 1);
351 }
352
353 #[test]
354 fn data_len_geometry() {
355 let hints = StructuralHints::default();
356 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 assert_eq!(data_len(&h, &hints, false), 0);
363 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 h.set("NAXIS2", 50).unwrap();
371 assert_eq!(data_len(&h, &hints, false), 100 * 50 * 4);
372 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 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}