mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-25 16:44:41 +01:00
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:
1 parent
9c3f96649f
commit
781871ff93
1 file changed
+69
-6
@@ -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(&[
|
||||
|
||||
Reference in new issue
Block a user