Codecs: FITS keeps long CONTINUE strings intact on open and save (#4633)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Jason BrashearandBrandon Thomas authored and GitHub committed 2026-10-13 01:13:52 -04:00
1 parent 9c3f96649f
commit 781871ff93
1 file changed
+69 -6
+69 -6
View File
@@ -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<Header, CodecError> {
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(&[