diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 8da3849207..3aa769fb01 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -14,10 +14,12 @@ //! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN //! and blank pixels read as 0. //! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and -//! `HISTORY` by their text, `HIERARCH` keywords by their long name. +//! `HISTORY` by their text, `HIERARCH` keywords by their long name. Long strings continued +//! on `CONTINUE` cards are joined. //! //! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32 -//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards. +//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards +//! (strings longer than one card on `CONTINUE` cards). //! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused. use crate::Format; @@ -149,7 +151,20 @@ fn parse_header(b: &[u8], start: usize) -> Result { if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) { break; } - cards.push(parse_card(raw)); + let card = parse_card(raw); + // Long strings (FITS 4.0 section 4.2.1.2): a string ending in '&' goes on in the next + // `CONTINUE` record. An orphaned `CONTINUE` stays a card of its own (commentary). + if let Some(Card { value: Some(Value::Str(s)), .. }) = cards.last_mut() + && s.ends_with('&') + && card.key == "CONTINUE" + && card.text.starts_with(" ") + && let Value::Str(more) = parse_value(&card.text) + { + s.pop(); + s.push_str(&more); + continue; + } + cards.push(card); } let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?; Ok(Header { cards, data_start }) @@ -390,9 +405,22 @@ impl Writer { fn value(&mut self, key: &str, v: &str) { if literal(v) { self.card(&format!("{key:<8}= {v:>20}")); - } else { - self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10))); + return; } + // A string too long for one card goes on in `CONTINUE` cards, every piece but the last + // ending in '&' (FITS 4.0 section 4.2.1.2). A doubled quote is never split, and trailing + // blanks (not significant) are dropped so none are kept before an '&'. + let escaped = ascii(v).trim_end().replace('\'', "''"); + let mut rest = escaped.as_str(); + let mut head = format!("{key:<8}= "); + while rest.len() > CARD - 12 { + let cut = CARD - 13 - rest.get(..CARD - 13).unwrap_or("").bytes().rev().take_while(|&b| b == b'\'').count() % 2; + let (piece, tail) = rest.split_at_checked(cut).unwrap_or((rest, "")); + self.card(&format!("{head}'{piece}&'")); + head = "CONTINUE ".to_string(); + rest = tail; + } + self.card(&format!("{head}'{rest:<8}'")); } fn commentary(&mut self, key: &str, text: &str) { @@ -410,7 +438,8 @@ impl Writer { if matches!(upper.as_str(), "COMMENT" | "HISTORY") { self.commentary(&upper, v); } else if standard_key(key) { - if !STRUCTURAL.contains(&key) && !is_naxis(key) { + // `CONTINUE` only ever carries a string's continuation, written by `value`. + if !STRUCTURAL.contains(&key) && !is_naxis(key) && key != "CONTINUE" { self.value(key, v); } } else { @@ -526,6 +555,40 @@ mod tests { out } + #[test] + fn continue_strings_decode() { + let mut b = header(&[ + "SIMPLE = T", + "BITPIX = 8", + "NAXIS = 2", + "NAXIS1 = 1", + "NAXIS2 = 1", + "OBJECT = 'aaa&'", + "CONTINUE 'bbb&' / a comment", + "CONTINUE '&'", + "CONTINUE ''", + "OBSERVER= 'R&'", + "COMMENT hi", + "CONTINUE 'orphan'", + ]); + b.push(0); + let img = decode(&b, &Limits::default()).unwrap(); + let text = [("OBJECT", "aaabbb"), ("OBSERVER", "R&"), ("COMMENT", "hi"), ("CONTINUE", "'orphan'")]; + assert_eq!(img.meta.text, text.map(|(k, v)| (k.to_string(), v.to_string()))); + } + + #[test] + fn continue_strings_round_trip() { + for value in ["a".repeat(100), format!("{}' {}", "a".repeat(65), "b".repeat(100)), "'".repeat(100), "a".repeat(68)] { + let mut img = Image::from_u8(1, 1, ChannelLayout::Gray, vec![0]).unwrap(); + img.meta.text = vec![("OBJECT".into(), value.clone()), ("CONTINUE".into(), "orphan".into())]; + let b = crate::encode(&img, F, &EncodeOptions::default()).unwrap(); + assert!(!b.chunks(CARD).any(|c| c.starts_with(b"CONTINUE="))); + let decoded = decode(&b, &Limits::default()).unwrap(); + assert_eq!(decoded.meta.text, vec![("OBJECT".into(), value)]); + } + } + #[test] fn camera_u16_with_bzero_reads_exactly_and_bottom_up() { let mut b = header(&[