fits_well/header/mod.rs
1pub(crate) mod card;
2pub(crate) mod value;
3
4use std::collections::HashMap;
5
6use crate::bitpix::Bitpix;
7use crate::block::CARD_SIZE;
8use crate::data::Scaling;
9use crate::error::FitsError;
10use crate::error::Result;
11use crate::header::card::Card;
12use crate::header::card::CardKind;
13use crate::header::card::validate_keyword;
14use crate::header::value::Value;
15use crate::keyword::key;
16use crate::time::{EpochTime, FitsTime, PhaseAxis, TimeBounds};
17use crate::wcs::Wcs;
18
19/// A parsed header unit: an *ordered* list of content cards plus a side index
20/// for O(1) keyword lookup.
21///
22/// Order and duplicates are preserved exactly (commentary cards repeat, and
23/// record order is significant), so the model is a vector — never a map. The
24/// terminating `END` record is implicit and not stored as a card. Long-string
25/// values split across `CONTINUE` records are reassembled into a single value
26/// card on read and re-emitted as a canonical `CONTINUE` chain on write, so the
27/// round-trip preserves the logical model (not necessarily the original byte
28/// split).
29#[derive(Debug, Clone, Default)]
30pub struct Header {
31 pub(crate) cards: Vec<Card>,
32 /// First occurrence of each valued keyword → index into `cards`.
33 ///
34 /// Invariant: every entry points at a card that carries a value — a
35 /// [`CardKind::Value`] or [`CardKind::Hierarch`] — in `cards`.
36 /// `cards` is only ever appended/extended in place during `parse`, never
37 /// reordered, so the index stays valid. Any future card-mutation API must
38 /// rebuild this (or it must be made a method that maintains it) — do not
39 /// expose raw mutation that can desynchronize the two.
40 index: HashMap<String, usize>,
41}
42
43/// A read-only view of one stored header record, yielded by [`Header::iter`].
44///
45/// `value` is `None` for commentary (`COMMENT`/`HISTORY`/blank-keyword) cards and
46/// `Some` for valued ones — that distinction is all a caller needs, so the internal
47/// `CardKind` (which also tags transient parse states) stays private. `comment`
48/// carries the inline `/`-comment of a valued card, or the whole free text of a
49/// commentary card.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct HeaderEntry<'a> {
52 pub keyword: &'a str,
53 pub value: Option<&'a Value>,
54 pub comment: Option<&'a str>,
55}
56
57impl Header {
58 /// Parse a header unit from its raw bytes (a whole number of 80-byte cards;
59 /// the reader supplies block-aligned input). Stops at the `END` record.
60 pub fn parse(bytes: &[u8]) -> Result<Header> {
61 // One record per card is the upper bound (CONTINUE folding only merges,
62 // never adds; commentary cards skip the index), so reserve both once and
63 // let parsing fill them without the grow-reallocations a small header would
64 // otherwise pay on every push.
65 let ncards = bytes.len() / CARD_SIZE;
66 let mut cards: Vec<Card> = Vec::with_capacity(ncards);
67 let mut index = HashMap::with_capacity(ncards);
68 for chunk in bytes.chunks_exact(CARD_SIZE) {
69 let card = Card::parse(
70 chunk
71 .try_into()
72 .expect("chunks_exact yields CARD_SIZE slices"),
73 )?;
74 match card.kind {
75 CardKind::End => return Ok(Header { cards, index }),
76 CardKind::Continue if fold_continuation(&mut cards, &card) => {}
77 _ => {
78 let mut card = card;
79 // A CONTINUE with no value card to extend is malformed; keep it
80 // readable by demoting it to a commentary card.
81 if card.kind == CardKind::Continue {
82 card.kind = CardKind::Commentary;
83 card.value = None;
84 }
85 if matches!(card.kind, CardKind::Value | CardKind::Hierarch) {
86 index.entry(card.keyword.clone()).or_insert(cards.len());
87 }
88 cards.push(card);
89 }
90 }
91 }
92 Err(FitsError::MissingEnd)
93 }
94
95 /// The value of the first card with this keyword, if it is a valued card.
96 pub fn get(&self, keyword: &str) -> Option<&Value> {
97 self.index
98 .get(keyword)
99 .and_then(|&i| self.cards[i].value.as_ref())
100 }
101
102 pub fn get_logical(&self, keyword: &str) -> Option<bool> {
103 self.get(keyword)?.as_logical()
104 }
105
106 pub fn get_integer(&self, keyword: &str) -> Option<i64> {
107 self.get(keyword)?.as_integer()
108 }
109
110 pub fn get_real(&self, keyword: &str) -> Option<f64> {
111 self.get(keyword)?.as_real()
112 }
113
114 pub fn get_text(&self, keyword: &str) -> Option<&str> {
115 self.get(keyword)?.as_text()
116 }
117
118 /// Every stored record in file order, as [`HeaderEntry`] views — duplicates and
119 /// order preserved (the whole point of the ordered model), so `COMMENT`/`HISTORY`
120 /// runs and repeated keywords come through intact. The implicit `END` is not a
121 /// record and is never yielded. For valued keywords only, filter on
122 /// `e.value`: `header.iter().filter_map(|e| Some((e.keyword, e.value?)))`.
123 pub fn iter(&self) -> impl Iterator<Item = HeaderEntry<'_>> {
124 self.cards.iter().map(|c| HeaderEntry {
125 keyword: c.keyword.as_str(),
126 value: c.value.as_ref(),
127 comment: c.comment.as_deref(),
128 })
129 }
130
131 /// `BITPIX`, mapped to the typed element kind.
132 pub fn bitpix(&self) -> Result<Bitpix> {
133 let code = self
134 .get_integer("BITPIX")
135 .ok_or(FitsError::MissingKeyword { name: "BITPIX" })?;
136 Bitpix::from_code(code)
137 }
138
139 /// `NAXIS` — the number of axes (0 means no data array).
140 pub fn naxis(&self) -> Result<usize> {
141 let n = self
142 .get_integer("NAXIS")
143 .ok_or(FitsError::MissingKeyword { name: "NAXIS" })?;
144 // §4.4.1: `0 ≤ NAXIS ≤ 999`. Rejecting an out-of-range value is both
145 // conformance and a guard — `axes()` reserves `Vec::with_capacity(NAXIS)`,
146 // so an absurd `NAXIS` from an untrusted header would otherwise abort.
147 match usize::try_from(n) {
148 Ok(n) if n <= 999 => Ok(n),
149 _ => Err(FitsError::KeywordOutOfRange { name: "NAXIS" }),
150 }
151 }
152
153 /// The axis lengths `NAXIS1..NAXIS{NAXIS}`, in order.
154 pub fn axes(&self) -> Result<Vec<usize>> {
155 let naxis = self.naxis()?;
156 let mut axes = Vec::with_capacity(naxis);
157 for n in 1..=naxis {
158 let len = self
159 .get_integer(key!("NAXIS{n}").as_str())
160 .ok_or(FitsError::MissingKeyword { name: "NAXISn" })?;
161 axes.push(
162 usize::try_from(len)
163 .map_err(|_| FitsError::KeywordOutOfRange { name: "NAXISn" })?,
164 );
165 }
166 Ok(axes)
167 }
168
169 /// The physical-value scaling (`BSCALE`/`BZERO`/`BLANK`) declared by this header.
170 pub fn scaling(&self) -> Scaling {
171 Scaling::from_header(self)
172 }
173
174 /// Parse the World Coordinate System (FITS §8) described by this header: the
175 /// primary description (`alt = None`) or an alternate (`alt = Some('A'..='Z')`).
176 pub fn wcs(&self, alt: Option<char>) -> Result<Wcs> {
177 Wcs::from_header(self, alt)
178 }
179
180 /// WCS for a *pixel-list* table (§8.4.2), where the given `columns` hold the
181 /// coordinate axes; `alt` selects the primary (`None`) or an alternate system.
182 pub fn wcs_pixel_list(&self, columns: &[usize], alt: Option<char>) -> Result<Wcs> {
183 Wcs::from_pixel_list(self, columns, alt)
184 }
185
186 /// WCS attached to a single array-valued table `column` (§8.4.1).
187 pub fn wcs_array_column(&self, column: usize, alt: Option<char>) -> Result<Wcs> {
188 Wcs::from_array_column(self, column, alt)
189 }
190
191 /// The time-coordinate frame (FITS §9) parsed from this header — reference
192 /// epoch/scale, units, and any time WCS axis.
193 pub fn time(&self) -> FitsTime {
194 FitsTime::from_header(self)
195 }
196
197 /// The observation Modified Julian Date — `MJD-OBS`, else `DATE-OBS`, else the
198 /// `JEPOCH`/`BEPOCH` epoch, else `None`.
199 pub fn obs_mjd(&self) -> Option<f64> {
200 FitsTime::obs_mjd(self)
201 }
202
203 /// The Julian (`JEPOCH`) or Besselian (`BEPOCH`) epoch keyword, if present.
204 pub fn epoch(&self) -> Option<EpochTime> {
205 FitsTime::epoch(self)
206 }
207
208 /// The observation time bounds (start/end/duration, §9.2.3) from this header.
209 pub fn time_bounds(&self) -> TimeBounds {
210 FitsTime::bounds(self)
211 }
212
213 /// The §9.6 `'PHASE'` axis parameters for WCS `axis` (1-based), if it is one.
214 pub fn phase_axis(&self, axis: usize) -> Option<PhaseAxis> {
215 FitsTime::phase_axis(self, axis)
216 }
217
218 /// Create an empty header. Build it up with [`Header::set`] and friends.
219 pub fn new() -> Header {
220 Header::default()
221 }
222
223 /// Insert a valued keyword, or replace the value of an existing one, keeping
224 /// the keyword index in sync. Returns `&mut self` for chaining. The keyword
225 /// must be a valid FITS keyword name (≤ 8 chars of `A–Z`, `0–9`, `-`, `_`).
226 pub fn set(&mut self, keyword: &str, value: impl Into<Value>) -> &mut Self {
227 assert!(
228 validate_keyword(keyword).is_ok(),
229 "Header::set: invalid FITS keyword {keyword:?}"
230 );
231 let value = value.into();
232 if let Some(&i) = self.index.get(keyword) {
233 self.cards[i].value = Some(value);
234 } else {
235 self.index.insert(keyword.to_string(), self.cards.len());
236 self.cards.push(Card::value(keyword, value));
237 }
238 self
239 }
240
241 /// Remove every card with this keyword and rebuild the index. A no-op if the
242 /// keyword is absent. Used when transforming headers (e.g. stripping the `Z*`
243 /// keywords when uncompressing a tiled table).
244 #[cfg(feature = "compression")]
245 pub(crate) fn remove(&mut self, keyword: &str) -> &mut Self {
246 if self.index.contains_key(keyword) {
247 self.cards.retain(|c| c.keyword != keyword);
248 self.reindex();
249 }
250 self
251 }
252
253 /// Rebuild the keyword → first-card index after a structural edit.
254 #[cfg(feature = "compression")]
255 fn reindex(&mut self) {
256 self.index.clear();
257 for (i, card) in self.cards.iter().enumerate() {
258 if matches!(card.kind, CardKind::Value | CardKind::Hierarch) {
259 self.index.entry(card.keyword.clone()).or_insert(i);
260 }
261 }
262 }
263
264 /// Attach (or replace) the inline comment of an existing valued keyword;
265 /// a no-op if the keyword is absent.
266 pub fn comment(&mut self, keyword: &str, text: &str) -> &mut Self {
267 if let Some(&i) = self.index.get(keyword) {
268 self.cards[i].comment = Some(text.to_string());
269 }
270 self
271 }
272
273 /// Append a `COMMENT` card.
274 pub fn push_comment(&mut self, text: &str) -> &mut Self {
275 self.cards.push(Card::commentary("COMMENT", text));
276 self
277 }
278
279 /// Append a `HISTORY` card.
280 pub fn push_history(&mut self, text: &str) -> &mut Self {
281 self.cards.push(Card::commentary("HISTORY", text));
282 self
283 }
284}
285
286/// Fold a `CONTINUE` substring into the preceding long-string value card,
287/// returning `false` when the previous card is not a value awaiting continuation
288/// (i.e. a [`Value::Text`] whose text ends with the `&` continuation flag).
289fn fold_continuation(cards: &mut [Card], cont: &Card) -> bool {
290 let Some(prev) = cards.last_mut() else {
291 return false;
292 };
293 let Some(Value::Text(acc)) = prev.value.as_mut() else {
294 return false;
295 };
296 if !acc.ends_with('&') {
297 return false;
298 }
299 acc.pop(); // drop the continuation flag
300 if let Some(Value::Text(sub)) = &cont.value {
301 acc.push_str(sub);
302 }
303 // The convention carries any comment on the final CONTINUE record.
304 if cont.comment.is_some() {
305 prev.comment = cont.comment.clone();
306 }
307 true
308}
309
310/// Build a header from left-justified 80-column card text lines, appending the
311/// `END` record. Shared test helper for modules that exercise parsed headers.
312#[cfg(test)]
313pub(crate) fn from_card_lines(lines: &[&str]) -> Header {
314 let mut buf = Vec::with_capacity((lines.len() + 1) * CARD_SIZE);
315 for line in lines {
316 let mut card = [b' '; CARD_SIZE];
317 card[..line.len()].copy_from_slice(line.as_bytes());
318 buf.extend_from_slice(&card);
319 }
320 let mut end = [b' '; CARD_SIZE];
321 end[..3].copy_from_slice(b"END");
322 buf.extend_from_slice(&end);
323 Header::parse(&buf).unwrap()
324}
325
326#[cfg(test)]
327mod tests;